softopt 0.2.1__tar.gz → 0.4.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.
- {softopt-0.2.1/softopt.egg-info → softopt-0.4.0}/PKG-INFO +19 -16
- {softopt-0.2.1 → softopt-0.4.0}/README.md +18 -15
- {softopt-0.2.1 → softopt-0.4.0}/pyproject.toml +1 -1
- {softopt-0.2.1 → softopt-0.4.0}/softopt/__init__.py +6 -4
- softopt-0.4.0/softopt/compile.py +101 -0
- softopt-0.4.0/softopt/numpy_optimizer.py +98 -0
- {softopt-0.2.1 → softopt-0.4.0}/softopt/soft_number.py +72 -6
- {softopt-0.2.1 → softopt-0.4.0/softopt.egg-info}/PKG-INFO +19 -16
- {softopt-0.2.1 → softopt-0.4.0}/softopt.egg-info/SOURCES.txt +2 -0
- {softopt-0.2.1 → softopt-0.4.0}/tests/test_basic.py +1 -15
- softopt-0.4.0/tests/test_compile.py +147 -0
- softopt-0.2.1/softopt/numpy_optimizer.py +0 -168
- {softopt-0.2.1 → softopt-0.4.0}/LICENSE +0 -0
- {softopt-0.2.1 → softopt-0.4.0}/setup.cfg +0 -0
- {softopt-0.2.1 → softopt-0.4.0}/softopt/torch_optimizer.py +0 -0
- {softopt-0.2.1 → softopt-0.4.0}/softopt.egg-info/dependency_links.txt +0 -0
- {softopt-0.2.1 → softopt-0.4.0}/softopt.egg-info/requires.txt +0 -0
- {softopt-0.2.1 → softopt-0.4.0}/softopt.egg-info/top_level.txt +0 -0
- {softopt-0.2.1 → softopt-0.4.0}/tests/test_soft_number.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: softopt
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: A standalone optimizer for problems with a known computation graph, built on Klein–Maimon soft-number calculus
|
|
5
5
|
Author: Ido Angel
|
|
6
6
|
License: MIT
|
|
@@ -58,19 +58,31 @@ pip install "softopt[torch]" # + the PyTorch optimizer (quotes needed on zsh,
|
|
|
58
58
|
### NumPy
|
|
59
59
|
|
|
60
60
|
```python
|
|
61
|
-
|
|
61
|
+
import numpy as np
|
|
62
|
+
from softopt import SoftOpt, soft_compile
|
|
62
63
|
|
|
63
|
-
#
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
64
|
+
# Write your model once, in plain Python. No special arithmetic.
|
|
65
|
+
def my_model(theta):
|
|
66
|
+
x, y = theta
|
|
67
|
+
return (x - 2.0)**2 + np.exp(y) * x
|
|
68
|
+
|
|
69
|
+
# soft_compile turns it into the exact directional-derivative function
|
|
70
|
+
# SoftOpt needs, and verifies the result against your own model.
|
|
71
|
+
g_delta = soft_compile(my_model, n_params=2)
|
|
72
|
+
|
|
73
|
+
opt = SoftOpt(2, g_delta, lr=0.02)
|
|
68
74
|
|
|
69
75
|
for step in range(num_steps):
|
|
70
76
|
grad_estimate = my_gradient_estimate(theta) # e.g. from SPSA on real hardware
|
|
71
77
|
theta = opt.step(theta, grad_estimate)
|
|
72
78
|
```
|
|
73
79
|
|
|
80
|
+
Your model needs to be built from arithmetic and the elementary functions the soft algebra defines: `+ - * / **` (including negative and fractional exponents, and `2.0 ** x`), `abs`, `sqrt`, `exp`, `log`, `log10`, `sin`, `cos`, `tanh`, comparisons, and numpy's versions of those. Loops and `if`/`else` branching on parameter values work too — the compiled derivative is then valid on the branch your parameters are actually in.
|
|
81
|
+
|
|
82
|
+
The one thing to avoid is converting a traced value back to a plain number mid-model — `float(x)`, or `np.array(params)` on the parameter list, both silently discard the derivative. `soft_compile`'s verification catches this and says so.
|
|
83
|
+
|
|
84
|
+
If you'd rather write the derivative function yourself — because your model lives in a simulator SoftOpt can't trace, or because you want the speed of a hand-tuned implementation — pass any `g_delta(theta, delta)` that returns the exact directional derivative, and skip `soft_compile` entirely.
|
|
85
|
+
|
|
74
86
|
### PyTorch
|
|
75
87
|
|
|
76
88
|
```python
|
|
@@ -84,15 +96,6 @@ for batch in data:
|
|
|
84
96
|
|
|
85
97
|
`loss_fn(model, batch)` must be an exactly re-evaluatable, differentiable function of the model's own parameters — a full-batch loss, a physics simulator, a known projection model. SoftOpt uses PyTorch's own forward-mode autodiff (`torch.func.jvp`) to get the same exact directional derivative and curvature the NumPy version computes by hand.
|
|
86
98
|
|
|
87
|
-
## Two correction modes
|
|
88
|
-
|
|
89
|
-
SoftOpt ships with two ways of turning the computed derivative/curvature into a parameter update:
|
|
90
|
-
|
|
91
|
-
- **`newton`** (default) — a bounded Newton step `t* = clip(-D1/D2, bounds)`. Best when the curvature (D2) is consistently one-signed along random directions — true for essentially every gradient-based physics/circuit optimization problem we tested (VQE, GRAPE, camera calibration, portfolio, ...).
|
|
92
|
-
- **`mobius`** — a bounded, sign-safe step derived from the book's own Möbius map (Ch. 5.3), for problems whose curvature is *not* reliably one-signed. Pass `mode="mobius"` to `SoftOpt`/`SoftOptTorch` if `newton` underperforms plain Adam on your problem — that pattern is itself informative about your landscape's curvature.
|
|
93
|
-
|
|
94
|
-
**How do I know which one to use?** Right now, empirically: run a short comparison against plain Adam with `mode="newton"` first; if it clearly loses, try `mode="mobius"`. There is also an experimental `mode="auto"` that samples curvature sign near your starting point and picks for you — we tested it honestly and it is **not yet reliable** (on GRAPE, a domain we know needs `newton` with high confidence, it only picked correctly 40% of the time across random starting points). It's included so you can inspect `opt.detected_mode` and help us characterize when it works, but don't depend on it yet.
|
|
95
|
-
|
|
96
99
|
## Validated results
|
|
97
100
|
|
|
98
101
|
All results below use IBM's `FakeFez` noise model via Qiskit + Aer, or realistic finite-sample/measurement noise for the non-quantum domains, with `torch.optim.Adam` as the baseline. Improvement is the reduction in gap to the known optimum (or, for Portfolio, the reduction in loss).
|
|
@@ -31,19 +31,31 @@ pip install "softopt[torch]" # + the PyTorch optimizer (quotes needed on zsh,
|
|
|
31
31
|
### NumPy
|
|
32
32
|
|
|
33
33
|
```python
|
|
34
|
-
|
|
34
|
+
import numpy as np
|
|
35
|
+
from softopt import SoftOpt, soft_compile
|
|
35
36
|
|
|
36
|
-
#
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
37
|
+
# Write your model once, in plain Python. No special arithmetic.
|
|
38
|
+
def my_model(theta):
|
|
39
|
+
x, y = theta
|
|
40
|
+
return (x - 2.0)**2 + np.exp(y) * x
|
|
41
|
+
|
|
42
|
+
# soft_compile turns it into the exact directional-derivative function
|
|
43
|
+
# SoftOpt needs, and verifies the result against your own model.
|
|
44
|
+
g_delta = soft_compile(my_model, n_params=2)
|
|
45
|
+
|
|
46
|
+
opt = SoftOpt(2, g_delta, lr=0.02)
|
|
41
47
|
|
|
42
48
|
for step in range(num_steps):
|
|
43
49
|
grad_estimate = my_gradient_estimate(theta) # e.g. from SPSA on real hardware
|
|
44
50
|
theta = opt.step(theta, grad_estimate)
|
|
45
51
|
```
|
|
46
52
|
|
|
53
|
+
Your model needs to be built from arithmetic and the elementary functions the soft algebra defines: `+ - * / **` (including negative and fractional exponents, and `2.0 ** x`), `abs`, `sqrt`, `exp`, `log`, `log10`, `sin`, `cos`, `tanh`, comparisons, and numpy's versions of those. Loops and `if`/`else` branching on parameter values work too — the compiled derivative is then valid on the branch your parameters are actually in.
|
|
54
|
+
|
|
55
|
+
The one thing to avoid is converting a traced value back to a plain number mid-model — `float(x)`, or `np.array(params)` on the parameter list, both silently discard the derivative. `soft_compile`'s verification catches this and says so.
|
|
56
|
+
|
|
57
|
+
If you'd rather write the derivative function yourself — because your model lives in a simulator SoftOpt can't trace, or because you want the speed of a hand-tuned implementation — pass any `g_delta(theta, delta)` that returns the exact directional derivative, and skip `soft_compile` entirely.
|
|
58
|
+
|
|
47
59
|
### PyTorch
|
|
48
60
|
|
|
49
61
|
```python
|
|
@@ -57,15 +69,6 @@ for batch in data:
|
|
|
57
69
|
|
|
58
70
|
`loss_fn(model, batch)` must be an exactly re-evaluatable, differentiable function of the model's own parameters — a full-batch loss, a physics simulator, a known projection model. SoftOpt uses PyTorch's own forward-mode autodiff (`torch.func.jvp`) to get the same exact directional derivative and curvature the NumPy version computes by hand.
|
|
59
71
|
|
|
60
|
-
## Two correction modes
|
|
61
|
-
|
|
62
|
-
SoftOpt ships with two ways of turning the computed derivative/curvature into a parameter update:
|
|
63
|
-
|
|
64
|
-
- **`newton`** (default) — a bounded Newton step `t* = clip(-D1/D2, bounds)`. Best when the curvature (D2) is consistently one-signed along random directions — true for essentially every gradient-based physics/circuit optimization problem we tested (VQE, GRAPE, camera calibration, portfolio, ...).
|
|
65
|
-
- **`mobius`** — a bounded, sign-safe step derived from the book's own Möbius map (Ch. 5.3), for problems whose curvature is *not* reliably one-signed. Pass `mode="mobius"` to `SoftOpt`/`SoftOptTorch` if `newton` underperforms plain Adam on your problem — that pattern is itself informative about your landscape's curvature.
|
|
66
|
-
|
|
67
|
-
**How do I know which one to use?** Right now, empirically: run a short comparison against plain Adam with `mode="newton"` first; if it clearly loses, try `mode="mobius"`. There is also an experimental `mode="auto"` that samples curvature sign near your starting point and picks for you — we tested it honestly and it is **not yet reliable** (on GRAPE, a domain we know needs `newton` with high confidence, it only picked correctly 40% of the time across random starting points). It's included so you can inspect `opt.detected_mode` and help us characterize when it works, but don't depend on it yet.
|
|
68
|
-
|
|
69
72
|
## Validated results
|
|
70
73
|
|
|
71
74
|
All results below use IBM's `FakeFez` noise model via Qiskit + Aer, or realistic finite-sample/measurement noise for the non-quantum domains, with `torch.optim.Adam` as the baseline. Improvement is the reduction in gap to the known optimum (or, for Portfolio, the reduction in loss).
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "softopt"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.4.0"
|
|
8
8
|
description = "A standalone optimizer for problems with a known computation graph, built on Klein–Maimon soft-number calculus"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "MIT" }
|
|
@@ -5,23 +5,25 @@ calculus, for problems with a known, differentiable computation graph
|
|
|
5
5
|
|
|
6
6
|
from softopt import SoftOpt # numpy version
|
|
7
7
|
from softopt import SoftOptTorch # PyTorch version (torch.optim.Optimizer)
|
|
8
|
+
from softopt import soft_compile # turn a plain-Python model into g_delta
|
|
8
9
|
from softopt import SoftNumber # the algebraic primitive itself
|
|
9
10
|
|
|
10
11
|
See README.md for the exact requirement your problem must satisfy
|
|
11
12
|
(the "known computation graph" criterion) and worked examples.
|
|
12
13
|
"""
|
|
13
14
|
from .numpy_optimizer import SoftOpt
|
|
15
|
+
from .compile import soft_compile
|
|
14
16
|
from .soft_number import (
|
|
15
17
|
SoftNumber, sadd, sneg, ssub, smul, sinv, sdiv,
|
|
16
|
-
spow, sroot, ssqrt, ssin, scos, sexp,
|
|
18
|
+
spow, sroot, ssqrt, ssin, scos, sexp, slog,
|
|
17
19
|
)
|
|
18
20
|
|
|
19
21
|
__all__ = [
|
|
20
|
-
"SoftOpt", "SoftNumber",
|
|
22
|
+
"SoftOpt", "SoftNumber", "soft_compile",
|
|
21
23
|
"sadd", "sneg", "ssub", "smul", "sinv", "sdiv",
|
|
22
|
-
"spow", "sroot", "ssqrt", "ssin", "scos", "sexp",
|
|
24
|
+
"spow", "sroot", "ssqrt", "ssin", "scos", "sexp", "slog",
|
|
23
25
|
]
|
|
24
|
-
__version__ = "0.
|
|
26
|
+
__version__ = "0.4.0"
|
|
25
27
|
|
|
26
28
|
try:
|
|
27
29
|
from .torch_optimizer import SoftOpt as SoftOptTorch
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""
|
|
2
|
+
soft_compile: turn a plain-Python model into the exact directional-
|
|
3
|
+
derivative function (`g_delta`) that SoftOpt needs -- without writing any
|
|
4
|
+
soft-number arithmetic yourself.
|
|
5
|
+
|
|
6
|
+
from softopt import SoftOpt, soft_compile
|
|
7
|
+
|
|
8
|
+
def my_model(theta): # plain Python. numpy works too.
|
|
9
|
+
x, y = theta
|
|
10
|
+
return x**2 + np.exp(y) * x
|
|
11
|
+
|
|
12
|
+
g_delta = soft_compile(my_model, n_params=2)
|
|
13
|
+
opt = SoftOpt(2, g_delta, lr=0.02)
|
|
14
|
+
|
|
15
|
+
Your model is traced once with SoftNumber objects in place of floats, so
|
|
16
|
+
every operation carries its exact derivative alongside its value. This is
|
|
17
|
+
exact -- not a finite-difference approximation.
|
|
18
|
+
|
|
19
|
+
Your model must be built from arithmetic and the elementary functions the
|
|
20
|
+
soft algebra defines (+ - * / ** sqrt exp sin cos, and numpy's versions of
|
|
21
|
+
those). Branching on a parameter's *value* (`if x > 0:`) traces only the
|
|
22
|
+
branch taken at that point, which is correct locally but means the
|
|
23
|
+
compiled function is valid piecewise -- see `verify=True`.
|
|
24
|
+
"""
|
|
25
|
+
import numpy as np
|
|
26
|
+
|
|
27
|
+
from .soft_number import SoftNumber
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def soft_compile(model_fn, n_params=None, verify=True, verify_tol=1e-5):
|
|
31
|
+
"""model_fn: takes a sequence of scalars, returns a scalar.
|
|
32
|
+
n_params: needed only for verification.
|
|
33
|
+
verify: check the compiled derivative against finite differences at a
|
|
34
|
+
random point before returning it. Catches the common mistakes --
|
|
35
|
+
a model that isn't actually differentiable, uses an unsupported
|
|
36
|
+
operation, or silently drops the traced parameters."""
|
|
37
|
+
def g_delta(theta, delta):
|
|
38
|
+
soft = [SoftNumber(float(d), float(t)) for t, d in zip(theta, delta)]
|
|
39
|
+
result = model_fn(soft)
|
|
40
|
+
if not isinstance(result, SoftNumber):
|
|
41
|
+
raise TypeError(
|
|
42
|
+
"the compiled model returned a plain number, not a SoftNumber "
|
|
43
|
+
"-- its output doesn't depend on the traced parameters, or an "
|
|
44
|
+
"operation in it discarded them (e.g. float(), np.array(...), "
|
|
45
|
+
"or comparison-based rounding)"
|
|
46
|
+
)
|
|
47
|
+
return result.a
|
|
48
|
+
|
|
49
|
+
if verify:
|
|
50
|
+
if n_params is None:
|
|
51
|
+
raise ValueError("pass n_params to verify the compiled model, "
|
|
52
|
+
"or verify=False to skip the check")
|
|
53
|
+
_verify(model_fn, g_delta, n_params, verify_tol)
|
|
54
|
+
|
|
55
|
+
return g_delta
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _verify(model_fn, g_delta, n_params, tol):
|
|
59
|
+
"""Compare the exact compiled derivative against a finite difference of
|
|
60
|
+
the same model evaluated with plain floats."""
|
|
61
|
+
rng = np.random.default_rng(0)
|
|
62
|
+
for attempt in range(5):
|
|
63
|
+
theta = rng.normal(0.5, 1.0, n_params)
|
|
64
|
+
direction = rng.normal(0, 1, n_params)
|
|
65
|
+
try:
|
|
66
|
+
exact = g_delta(theta, direction)
|
|
67
|
+
except ZeroDivisionError:
|
|
68
|
+
continue # landed on a singular point; try another
|
|
69
|
+
h = 1e-6
|
|
70
|
+
try:
|
|
71
|
+
f_plus = float(model_fn(list(theta + h * direction)))
|
|
72
|
+
f_minus = float(model_fn(list(theta - h * direction)))
|
|
73
|
+
except Exception as e:
|
|
74
|
+
raise RuntimeError(
|
|
75
|
+
f"the model raised {type(e).__name__} when called with plain "
|
|
76
|
+
f"floats: {e}. soft_compile needs a model that works with both "
|
|
77
|
+
"floats and SoftNumbers."
|
|
78
|
+
) from e
|
|
79
|
+
approx = (f_plus - f_minus) / (2 * h)
|
|
80
|
+
|
|
81
|
+
scale = max(abs(approx), abs(exact), 1.0)
|
|
82
|
+
if abs(exact - approx) / scale < tol:
|
|
83
|
+
return # verified
|
|
84
|
+
if abs(approx) < 1e-12 and abs(exact) < 1e-12:
|
|
85
|
+
continue # flat direction here; uninformative, try another
|
|
86
|
+
|
|
87
|
+
raise RuntimeError(
|
|
88
|
+
f"compiled derivative ({exact:.8g}) disagrees with a finite "
|
|
89
|
+
f"difference of your model ({approx:.8g}). Your model may use an "
|
|
90
|
+
"operation the soft algebra doesn't define, or may not be "
|
|
91
|
+
"differentiable at this point. Pass verify=False to skip this "
|
|
92
|
+
"check if you're confident it's correct."
|
|
93
|
+
)
|
|
94
|
+
# every attempt landed somewhere uninformative -- don't fail, but don't
|
|
95
|
+
# claim verification either
|
|
96
|
+
import warnings
|
|
97
|
+
warnings.warn(
|
|
98
|
+
"soft_compile could not verify the model: every test point was "
|
|
99
|
+
"either singular or flat. The compiled function is returned unchecked.",
|
|
100
|
+
RuntimeWarning,
|
|
101
|
+
)
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SoftOpt: a COMPLETE, standalone optimizer -- not a wrapper around Adam.
|
|
3
|
+
Exactly one step() call does both the Adam-style base update AND the
|
|
4
|
+
validated Newton-style correction. You don't bring your own optimizer;
|
|
5
|
+
this IS the optimizer, in the same sense that torch.optim.Adam IS an
|
|
6
|
+
optimizer (internally momentum + RMS-scaling), not a wrapper around SGD.
|
|
7
|
+
|
|
8
|
+
Requirement: you supply g_delta(theta, delta) -> the exact directional
|
|
9
|
+
derivative of your true objective along delta, computed from your own
|
|
10
|
+
known model (circuit, projection, physical law). `soft_compile` generates
|
|
11
|
+
this for you from a model written in ordinary Python.
|
|
12
|
+
|
|
13
|
+
The correction is a bounded Newton step derived from the exact first and
|
|
14
|
+
second directional derivatives of your model.
|
|
15
|
+
"""
|
|
16
|
+
import numpy as np
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class SoftOpt:
|
|
20
|
+
def __init__(self, n_params, g_delta, lr=0.02, betas=(0.9, 0.999), eps=1e-8,
|
|
21
|
+
newton_lo=-0.5, newton_hi=0.5, eta_fallback=0.3,
|
|
22
|
+
h_fd=1e-4, seed=None, _test_frozen_constant=1.0):
|
|
23
|
+
self.g_delta = g_delta
|
|
24
|
+
self.lr = lr
|
|
25
|
+
self.b1, self.b2 = betas
|
|
26
|
+
self.eps = eps
|
|
27
|
+
self.newton_lo = newton_lo
|
|
28
|
+
self.newton_hi = newton_hi
|
|
29
|
+
self.eta_fallback = eta_fallback
|
|
30
|
+
self.h_fd = h_fd
|
|
31
|
+
self.m = None
|
|
32
|
+
self.v = None
|
|
33
|
+
self.t = 0
|
|
34
|
+
self.rng = np.random.default_rng(seed)
|
|
35
|
+
self._test_frozen_constant = _test_frozen_constant
|
|
36
|
+
|
|
37
|
+
def step(self, theta, grad_estimate, _test_magnitude_source=None, _test_foreign_sampler=None, _test_rng=None):
|
|
38
|
+
"""One complete optimizer step: Adam base update + Newton-style
|
|
39
|
+
correction from the known model, in a single call.
|
|
40
|
+
theta: current parameters (np.ndarray)
|
|
41
|
+
grad_estimate: a gradient estimate of the loss w.r.t. theta (e.g.
|
|
42
|
+
from SPSA or backprop) -- exactly what you'd normally hand
|
|
43
|
+
to Adam.
|
|
44
|
+
Returns: theta for the next step.
|
|
45
|
+
|
|
46
|
+
The _test_* arguments are TEST-ONLY hooks used exclusively by the
|
|
47
|
+
causal-validation ablation harness to deliberately corrupt the
|
|
48
|
+
correction (feed a frozen constant or a foreign point's curvature
|
|
49
|
+
instead of the genuine one) so that "does real content matter"
|
|
50
|
+
can be checked against the EXACT SAME code path real usage takes.
|
|
51
|
+
Normal usage never sets these; leaving them unset gives the
|
|
52
|
+
genuine, real behavior.
|
|
53
|
+
"""
|
|
54
|
+
theta = np.asarray(theta, dtype=float)
|
|
55
|
+
grad_estimate = np.asarray(grad_estimate, dtype=float)
|
|
56
|
+
|
|
57
|
+
# --- Adam base update ---
|
|
58
|
+
self.t += 1
|
|
59
|
+
if self.m is None:
|
|
60
|
+
self.m = np.zeros_like(theta)
|
|
61
|
+
self.v = np.zeros_like(theta)
|
|
62
|
+
self.m = self.b1 * self.m + (1 - self.b1) * grad_estimate
|
|
63
|
+
self.v = self.b2 * self.v + (1 - self.b2) * grad_estimate ** 2
|
|
64
|
+
mh = self.m / (1 - self.b1 ** self.t)
|
|
65
|
+
vh = self.v / (1 - self.b2 ** self.t)
|
|
66
|
+
theta_after_adam = theta - self.lr * mh / (np.sqrt(vh) + self.eps)
|
|
67
|
+
|
|
68
|
+
if _test_magnitude_source == 'plain':
|
|
69
|
+
return theta_after_adam
|
|
70
|
+
|
|
71
|
+
# --- correction from the known, exact model ---
|
|
72
|
+
n = theta.shape[0]
|
|
73
|
+
gen_rng = _test_rng if _test_rng is not None else self.rng
|
|
74
|
+
delta = gen_rng.choice([-1.0, 1.0], size=n)
|
|
75
|
+
D1 = self.g_delta(theta_after_adam, delta)
|
|
76
|
+
gp = self.g_delta(theta_after_adam + self.h_fd * delta, delta)
|
|
77
|
+
gm = self.g_delta(theta_after_adam - self.h_fd * delta, delta)
|
|
78
|
+
D2_real = (gp - gm) / (2 * self.h_fd)
|
|
79
|
+
|
|
80
|
+
if _test_magnitude_source in (None, 'real'):
|
|
81
|
+
D2_used = D2_real
|
|
82
|
+
elif _test_magnitude_source == 'frozen':
|
|
83
|
+
D2_used = self._test_frozen_constant
|
|
84
|
+
elif _test_magnitude_source in ('foreign_point_and_dir', 'foreign_point_same_dir'):
|
|
85
|
+
theta_f = _test_foreign_sampler(gen_rng)
|
|
86
|
+
delta_f = gen_rng.choice([-1.0, 1.0], size=n) if _test_magnitude_source == 'foreign_point_and_dir' else delta
|
|
87
|
+
gp_f = self.g_delta(theta_f + self.h_fd * delta_f, delta_f)
|
|
88
|
+
gm_f = self.g_delta(theta_f - self.h_fd * delta_f, delta_f)
|
|
89
|
+
D2_used = abs((gp_f - gm_f) / (2 * self.h_fd))
|
|
90
|
+
else:
|
|
91
|
+
raise ValueError(_test_magnitude_source)
|
|
92
|
+
|
|
93
|
+
if D2_real > 1e-8 and D2_used > 1e-9:
|
|
94
|
+
t_star = float(np.clip(-D1 / D2_used, self.newton_lo, self.newton_hi))
|
|
95
|
+
else:
|
|
96
|
+
t_star = float(np.clip(-self.eta_fallback * D1, self.newton_lo, self.newton_hi))
|
|
97
|
+
|
|
98
|
+
return theta_after_adam + t_star * delta
|
|
@@ -56,13 +56,21 @@ def sdiv(x, y):
|
|
|
56
56
|
|
|
57
57
|
# --- Section 3.3.4: Lemma 3.3, integer powers ---
|
|
58
58
|
def spow(x, n):
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
"integers only; use sroot(x, n) for Lemma 3.4/3.5")
|
|
59
|
+
"""Integer powers via Lemma 3.3; fractional powers by composing
|
|
60
|
+
Lemma 3.3 with Lemma 3.5's root (e.g. x**2.5 = (x**5)**(1/2))."""
|
|
62
61
|
a, b = x
|
|
63
|
-
if n
|
|
64
|
-
|
|
65
|
-
|
|
62
|
+
if isinstance(n, float) and n.is_integer():
|
|
63
|
+
n = int(n)
|
|
64
|
+
if isinstance(n, int):
|
|
65
|
+
if n < 0:
|
|
66
|
+
return sinv(spow(x, -n))
|
|
67
|
+
if n == 0:
|
|
68
|
+
return (0.0, 1.0)
|
|
69
|
+
return (n * a * b ** (n - 1), b**n)
|
|
70
|
+
# fractional: x**(p/q). Use the chain rule form directly -- for
|
|
71
|
+
# f(t)=t^n the extension rule gives (a*n*b^(n-1), b^n), which holds
|
|
72
|
+
# for real n as well, by Lemma 6.1's general f(a,b)=(a*f'(b), f(b)).
|
|
73
|
+
return (a * n * b ** (n - 1), b**n)
|
|
66
74
|
|
|
67
75
|
|
|
68
76
|
# --- Section 3.3.5: Lemma 3.4 (square root), Lemma 3.5 (n-th root) ---
|
|
@@ -106,6 +114,14 @@ def sexp(x):
|
|
|
106
114
|
return (a * e, e)
|
|
107
115
|
|
|
108
116
|
|
|
117
|
+
def slog(x):
|
|
118
|
+
"""Natural logarithm, Lemma 6.1's general rule with f(t)=ln(t)."""
|
|
119
|
+
a, b = x
|
|
120
|
+
if b <= 0:
|
|
121
|
+
raise ValueError("slog requires a positive real component")
|
|
122
|
+
return (a / b, np.log(b))
|
|
123
|
+
|
|
124
|
+
|
|
109
125
|
class SoftNumber:
|
|
110
126
|
"""Object wrapper around the tuple functions above -- same math,
|
|
111
127
|
operator syntax. See module docstring."""
|
|
@@ -157,8 +173,58 @@ class SoftNumber:
|
|
|
157
173
|
return self._coerce(other) / self
|
|
158
174
|
|
|
159
175
|
def __pow__(self, n):
|
|
176
|
+
if isinstance(n, SoftNumber):
|
|
177
|
+
# x**y with both soft: x**y = exp(y * ln x)
|
|
178
|
+
return (self.log() * n).exp()
|
|
160
179
|
return self._from_tuple(spow(self.as_tuple(), n))
|
|
161
180
|
|
|
181
|
+
def __rpow__(self, base):
|
|
182
|
+
"""base ** self, where base is an ordinary number.
|
|
183
|
+
d/dt base^t = base^t * ln(base)."""
|
|
184
|
+
if base <= 0:
|
|
185
|
+
raise ValueError(f"{base}**SoftNumber requires a positive base")
|
|
186
|
+
val = base ** self.b
|
|
187
|
+
return SoftNumber(self.a * val * np.log(base), val)
|
|
188
|
+
|
|
189
|
+
def log(self):
|
|
190
|
+
"""Natural log, via Lemma 6.1's general rule with f=ln."""
|
|
191
|
+
return self._from_tuple(slog(self.as_tuple()))
|
|
192
|
+
|
|
193
|
+
def tanh(self):
|
|
194
|
+
t = np.tanh(self.b)
|
|
195
|
+
return SoftNumber(self.a * (1.0 - t * t), t)
|
|
196
|
+
|
|
197
|
+
def log10(self):
|
|
198
|
+
return SoftNumber(self.a / (self.b * np.log(10.0)), np.log10(self.b))
|
|
199
|
+
|
|
200
|
+
def __abs__(self):
|
|
201
|
+
"""|x|: the real part determines the branch; the derivative follows
|
|
202
|
+
its sign (undefined exactly at 0, as for any absolute value)."""
|
|
203
|
+
if self.b < 0:
|
|
204
|
+
return -self
|
|
205
|
+
return SoftNumber(self.a, self.b)
|
|
206
|
+
|
|
207
|
+
# --- comparisons: ordered by the real component (book Definition 3.1,
|
|
208
|
+
# "Order": if a < b then a0 < b0). This lets models branch on parameter
|
|
209
|
+
# values normally; the compiled derivative is then valid on that branch.
|
|
210
|
+
def __lt__(self, other):
|
|
211
|
+
return self.b < self._coerce(other).b
|
|
212
|
+
|
|
213
|
+
def __le__(self, other):
|
|
214
|
+
return self.b <= self._coerce(other).b
|
|
215
|
+
|
|
216
|
+
def __gt__(self, other):
|
|
217
|
+
return self.b > self._coerce(other).b
|
|
218
|
+
|
|
219
|
+
def __ge__(self, other):
|
|
220
|
+
return self.b >= self._coerce(other).b
|
|
221
|
+
|
|
222
|
+
def __float__(self):
|
|
223
|
+
"""The real component. Note this DISCARDS the derivative -- if your
|
|
224
|
+
model calls float() on a traced value, the compiled derivative will
|
|
225
|
+
be wrong, which soft_compile's verification is designed to catch."""
|
|
226
|
+
return float(self.b)
|
|
227
|
+
|
|
162
228
|
def root(self, n):
|
|
163
229
|
return self._from_tuple(sroot(self.as_tuple(), n))
|
|
164
230
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: softopt
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: A standalone optimizer for problems with a known computation graph, built on Klein–Maimon soft-number calculus
|
|
5
5
|
Author: Ido Angel
|
|
6
6
|
License: MIT
|
|
@@ -58,19 +58,31 @@ pip install "softopt[torch]" # + the PyTorch optimizer (quotes needed on zsh,
|
|
|
58
58
|
### NumPy
|
|
59
59
|
|
|
60
60
|
```python
|
|
61
|
-
|
|
61
|
+
import numpy as np
|
|
62
|
+
from softopt import SoftOpt, soft_compile
|
|
62
63
|
|
|
63
|
-
#
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
64
|
+
# Write your model once, in plain Python. No special arithmetic.
|
|
65
|
+
def my_model(theta):
|
|
66
|
+
x, y = theta
|
|
67
|
+
return (x - 2.0)**2 + np.exp(y) * x
|
|
68
|
+
|
|
69
|
+
# soft_compile turns it into the exact directional-derivative function
|
|
70
|
+
# SoftOpt needs, and verifies the result against your own model.
|
|
71
|
+
g_delta = soft_compile(my_model, n_params=2)
|
|
72
|
+
|
|
73
|
+
opt = SoftOpt(2, g_delta, lr=0.02)
|
|
68
74
|
|
|
69
75
|
for step in range(num_steps):
|
|
70
76
|
grad_estimate = my_gradient_estimate(theta) # e.g. from SPSA on real hardware
|
|
71
77
|
theta = opt.step(theta, grad_estimate)
|
|
72
78
|
```
|
|
73
79
|
|
|
80
|
+
Your model needs to be built from arithmetic and the elementary functions the soft algebra defines: `+ - * / **` (including negative and fractional exponents, and `2.0 ** x`), `abs`, `sqrt`, `exp`, `log`, `log10`, `sin`, `cos`, `tanh`, comparisons, and numpy's versions of those. Loops and `if`/`else` branching on parameter values work too — the compiled derivative is then valid on the branch your parameters are actually in.
|
|
81
|
+
|
|
82
|
+
The one thing to avoid is converting a traced value back to a plain number mid-model — `float(x)`, or `np.array(params)` on the parameter list, both silently discard the derivative. `soft_compile`'s verification catches this and says so.
|
|
83
|
+
|
|
84
|
+
If you'd rather write the derivative function yourself — because your model lives in a simulator SoftOpt can't trace, or because you want the speed of a hand-tuned implementation — pass any `g_delta(theta, delta)` that returns the exact directional derivative, and skip `soft_compile` entirely.
|
|
85
|
+
|
|
74
86
|
### PyTorch
|
|
75
87
|
|
|
76
88
|
```python
|
|
@@ -84,15 +96,6 @@ for batch in data:
|
|
|
84
96
|
|
|
85
97
|
`loss_fn(model, batch)` must be an exactly re-evaluatable, differentiable function of the model's own parameters — a full-batch loss, a physics simulator, a known projection model. SoftOpt uses PyTorch's own forward-mode autodiff (`torch.func.jvp`) to get the same exact directional derivative and curvature the NumPy version computes by hand.
|
|
86
98
|
|
|
87
|
-
## Two correction modes
|
|
88
|
-
|
|
89
|
-
SoftOpt ships with two ways of turning the computed derivative/curvature into a parameter update:
|
|
90
|
-
|
|
91
|
-
- **`newton`** (default) — a bounded Newton step `t* = clip(-D1/D2, bounds)`. Best when the curvature (D2) is consistently one-signed along random directions — true for essentially every gradient-based physics/circuit optimization problem we tested (VQE, GRAPE, camera calibration, portfolio, ...).
|
|
92
|
-
- **`mobius`** — a bounded, sign-safe step derived from the book's own Möbius map (Ch. 5.3), for problems whose curvature is *not* reliably one-signed. Pass `mode="mobius"` to `SoftOpt`/`SoftOptTorch` if `newton` underperforms plain Adam on your problem — that pattern is itself informative about your landscape's curvature.
|
|
93
|
-
|
|
94
|
-
**How do I know which one to use?** Right now, empirically: run a short comparison against plain Adam with `mode="newton"` first; if it clearly loses, try `mode="mobius"`. There is also an experimental `mode="auto"` that samples curvature sign near your starting point and picks for you — we tested it honestly and it is **not yet reliable** (on GRAPE, a domain we know needs `newton` with high confidence, it only picked correctly 40% of the time across random starting points). It's included so you can inspect `opt.detected_mode` and help us characterize when it works, but don't depend on it yet.
|
|
95
|
-
|
|
96
99
|
## Validated results
|
|
97
100
|
|
|
98
101
|
All results below use IBM's `FakeFez` noise model via Qiskit + Aer, or realistic finite-sample/measurement noise for the non-quantum domains, with `torch.optim.Adam` as the baseline. Improvement is the reduction in gap to the known optimum (or, for Portfolio, the reduction in loss).
|
|
@@ -2,6 +2,7 @@ LICENSE
|
|
|
2
2
|
README.md
|
|
3
3
|
pyproject.toml
|
|
4
4
|
softopt/__init__.py
|
|
5
|
+
softopt/compile.py
|
|
5
6
|
softopt/numpy_optimizer.py
|
|
6
7
|
softopt/soft_number.py
|
|
7
8
|
softopt/torch_optimizer.py
|
|
@@ -11,4 +12,5 @@ softopt.egg-info/dependency_links.txt
|
|
|
11
12
|
softopt.egg-info/requires.txt
|
|
12
13
|
softopt.egg-info/top_level.txt
|
|
13
14
|
tests/test_basic.py
|
|
15
|
+
tests/test_compile.py
|
|
14
16
|
tests/test_soft_number.py
|
|
@@ -14,7 +14,7 @@ def quadratic_g_delta(theta, delta):
|
|
|
14
14
|
return float(2 * theta @ delta)
|
|
15
15
|
|
|
16
16
|
|
|
17
|
-
def
|
|
17
|
+
def test_converges_on_convex_quadratic():
|
|
18
18
|
opt = SoftOpt(5, quadratic_g_delta, lr=0.1, seed=0)
|
|
19
19
|
theta = np.ones(5)
|
|
20
20
|
for _ in range(200):
|
|
@@ -23,20 +23,6 @@ def test_newton_mode_converges_on_convex_quadratic():
|
|
|
23
23
|
assert np.allclose(theta, 0.0, atol=1e-2)
|
|
24
24
|
|
|
25
25
|
|
|
26
|
-
def test_mobius_mode_runs_without_error():
|
|
27
|
-
opt = SoftOpt(5, quadratic_g_delta, lr=0.1, mode="mobius", seed=0)
|
|
28
|
-
theta = np.ones(5)
|
|
29
|
-
for _ in range(50):
|
|
30
|
-
grad = 2 * theta
|
|
31
|
-
theta = opt.step(theta, grad)
|
|
32
|
-
assert np.all(np.isfinite(theta))
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
def test_invalid_mode_raises():
|
|
36
|
-
with pytest.raises(ValueError):
|
|
37
|
-
SoftOpt(5, quadratic_g_delta, mode="not_a_real_mode")
|
|
38
|
-
|
|
39
|
-
|
|
40
26
|
def test_step_is_deterministic_given_seed():
|
|
41
27
|
opt1 = SoftOpt(3, quadratic_g_delta, lr=0.05, seed=42)
|
|
42
28
|
opt2 = SoftOpt(3, quadratic_g_delta, lr=0.05, seed=42)
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tests for soft_compile -- generating g_delta from a plain-Python model.
|
|
3
|
+
"""
|
|
4
|
+
import numpy as np
|
|
5
|
+
import pytest
|
|
6
|
+
from softopt import soft_compile, SoftOpt
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_polynomial_matches_finite_difference():
|
|
10
|
+
def model(p):
|
|
11
|
+
x, y, z = p
|
|
12
|
+
return x * x + y * y * y + x * y * z
|
|
13
|
+
|
|
14
|
+
g = soft_compile(model, n_params=3)
|
|
15
|
+
theta = np.array([1.3, -0.7, 2.1])
|
|
16
|
+
direction = np.array([1.0, -1.0, 1.0])
|
|
17
|
+
h = 1e-6
|
|
18
|
+
approx = (model(list(theta + h * direction)) - model(list(theta - h * direction))) / (2 * h)
|
|
19
|
+
assert np.isclose(g(theta, direction), approx, rtol=1e-6)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_elementary_functions_work():
|
|
23
|
+
def model(p):
|
|
24
|
+
x, y = p
|
|
25
|
+
return np.exp(x) * np.sin(y) + np.sqrt(x * x + 1.0)
|
|
26
|
+
|
|
27
|
+
g = soft_compile(model, n_params=2)
|
|
28
|
+
theta = np.array([0.4, 1.1])
|
|
29
|
+
direction = np.array([1.0, -1.0])
|
|
30
|
+
h = 1e-6
|
|
31
|
+
approx = (model(list(theta + h * direction)) - model(list(theta - h * direction))) / (2 * h)
|
|
32
|
+
assert np.isclose(g(theta, direction), approx, rtol=1e-5)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_fractional_and_negative_powers():
|
|
36
|
+
def model(p):
|
|
37
|
+
x, = p
|
|
38
|
+
return x ** 2.5 + x ** -1
|
|
39
|
+
|
|
40
|
+
g = soft_compile(model, n_params=1)
|
|
41
|
+
theta = np.array([1.7])
|
|
42
|
+
direction = np.array([1.0])
|
|
43
|
+
h = 1e-6
|
|
44
|
+
approx = (model(list(theta + h * direction)) - model(list(theta - h * direction))) / (2 * h)
|
|
45
|
+
assert np.isclose(g(theta, direction), approx, rtol=1e-5)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_model_ignoring_params_is_rejected():
|
|
49
|
+
def model(p):
|
|
50
|
+
return 5.0
|
|
51
|
+
|
|
52
|
+
with pytest.raises(TypeError, match="doesn't depend on the traced parameters"):
|
|
53
|
+
soft_compile(model, n_params=2)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_verify_requires_n_params():
|
|
57
|
+
with pytest.raises(ValueError, match="pass n_params"):
|
|
58
|
+
soft_compile(lambda p: p[0] * p[0])
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_verify_can_be_skipped():
|
|
62
|
+
g = soft_compile(lambda p: p[0] * p[0], verify=False)
|
|
63
|
+
assert np.isclose(g(np.array([3.0]), np.array([1.0])), 6.0)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_compiled_model_drives_the_optimizer():
|
|
67
|
+
"""End-to-end: compile a model, optimize with it, reach the minimum."""
|
|
68
|
+
def model(p):
|
|
69
|
+
x, y = p
|
|
70
|
+
return (x - 2.0) ** 2 + (y + 1.0) ** 2
|
|
71
|
+
|
|
72
|
+
g = soft_compile(model, n_params=2)
|
|
73
|
+
opt = SoftOpt(2, g, lr=0.1, seed=0)
|
|
74
|
+
theta = np.array([0.0, 0.0])
|
|
75
|
+
for _ in range(300):
|
|
76
|
+
grad = np.array([2 * (theta[0] - 2.0), 2 * (theta[1] + 1.0)])
|
|
77
|
+
theta = opt.step(theta, grad)
|
|
78
|
+
assert np.allclose(theta, [2.0, -1.0], atol=1e-2)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _fd_check(model, n, seed=77):
|
|
82
|
+
from softopt import soft_compile
|
|
83
|
+
g = soft_compile(model, n_params=n)
|
|
84
|
+
rng = np.random.default_rng(seed)
|
|
85
|
+
th = rng.normal(0.4, 0.9, n)
|
|
86
|
+
dl = rng.normal(0, 1, n)
|
|
87
|
+
exact = g(th, dl)
|
|
88
|
+
h = 1e-6
|
|
89
|
+
fd = (float(model(list(th + h * dl))) - float(model(list(th - h * dl)))) / (2 * h)
|
|
90
|
+
scale = max(abs(fd), abs(exact), 1.0)
|
|
91
|
+
assert abs(exact - fd) / scale < 1e-4, f"exact={exact} fd={fd}"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_loops_over_parameters():
|
|
95
|
+
def model(p):
|
|
96
|
+
x, = p
|
|
97
|
+
total = x * 0.0
|
|
98
|
+
for i in range(5):
|
|
99
|
+
total = total + x ** (i + 1) / (i + 1)
|
|
100
|
+
return total
|
|
101
|
+
_fd_check(model, 1)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_branching_on_parameter_value():
|
|
105
|
+
def model(p):
|
|
106
|
+
x, = p
|
|
107
|
+
return x * x if x > 0 else -x * x * x
|
|
108
|
+
_fd_check(model, 1)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_abs_and_max():
|
|
112
|
+
def model(p):
|
|
113
|
+
x, y = p
|
|
114
|
+
return abs(x) + max(y, 0.0)
|
|
115
|
+
_fd_check(model, 2)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_numpy_log_and_tanh():
|
|
119
|
+
def model(p):
|
|
120
|
+
x, = p
|
|
121
|
+
return np.log(np.exp(x) + 2.0) + np.tanh(x)
|
|
122
|
+
_fd_check(model, 1)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def test_base_to_the_power_of_a_parameter():
|
|
126
|
+
def model(p):
|
|
127
|
+
x, = p
|
|
128
|
+
return 2.0 ** x + np.e ** x
|
|
129
|
+
_fd_check(model, 1)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def test_object_array_quadratic_form():
|
|
133
|
+
def model(p):
|
|
134
|
+
v = np.array(p, dtype=object)
|
|
135
|
+
M = np.array([[1.0, 0.5], [0.5, 2.0]])
|
|
136
|
+
return sum(v[i] * M[i, j] * v[j] for i in range(2) for j in range(2))
|
|
137
|
+
_fd_check(model, 2)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def test_model_that_casts_to_float_is_caught():
|
|
141
|
+
"""np.array(p) on a list of SoftNumbers silently converts to floats,
|
|
142
|
+
dropping the derivative -- verification must catch this."""
|
|
143
|
+
def model(p):
|
|
144
|
+
v = np.array(p) # drops the soft components
|
|
145
|
+
return float(v @ v)
|
|
146
|
+
with pytest.raises(TypeError, match="doesn't depend on the traced parameters"):
|
|
147
|
+
soft_compile(model, n_params=2)
|
|
@@ -1,168 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
SoftOpt: a COMPLETE, standalone optimizer -- not a wrapper around Adam.
|
|
3
|
-
Exactly one step() call does both the Adam-style base update AND the
|
|
4
|
-
validated Newton-style correction. You don't bring your own optimizer;
|
|
5
|
-
this IS the optimizer, in the same sense that torch.optim.Adam IS an
|
|
6
|
-
optimizer (internally momentum + RMS-scaling), not a wrapper around SGD.
|
|
7
|
-
|
|
8
|
-
Requirement (the "known computation graph" criterion, validated across
|
|
9
|
-
nine domains today): you must supply g_delta(theta, delta) -> the exact
|
|
10
|
-
directional derivative of your TRUE, differentiable objective along
|
|
11
|
-
delta, computed via Klein-Maimon soft-number propagation through your
|
|
12
|
-
own known model (circuit, projection, force field, ...). If you cannot
|
|
13
|
-
write g_delta without sampling the world, SoftOpt is not the right tool
|
|
14
|
-
-- use plain Adam/SGD instead.
|
|
15
|
-
|
|
16
|
-
Three correction modes (see README for details):
|
|
17
|
-
- mode="newton" (default): bounded Newton step, best for one-signed
|
|
18
|
-
curvature (most physics/circuit problems).
|
|
19
|
-
- mode="mobius": bounded, sign-safe Mobius-map step (book Ch.5.3), for
|
|
20
|
-
landscapes whose curvature isn't reliably one-signed (e.g. QAOA).
|
|
21
|
-
- mode="auto" (EXPERIMENTAL, not yet reliable -- see README): attempts
|
|
22
|
-
to diagnose which of the above fits your problem by sampling D2's
|
|
23
|
-
sign near the starting point. Tested against GRAPE (a domain that
|
|
24
|
-
needs "newton" with high confidence) it only picked "newton" 40% of
|
|
25
|
-
the time -- a single starting region's local curvature sign is not
|
|
26
|
-
a reliable predictor of the right mode for the whole optimization.
|
|
27
|
-
Shipped anyway, clearly labeled, so you can inspect `opt.detected_mode`
|
|
28
|
-
and help characterize when it does/doesn't work, but do not rely on
|
|
29
|
-
it yet -- the empirically-validated way to choose is still: try both
|
|
30
|
-
modes on a short run and keep whichever beats plain Adam.
|
|
31
|
-
"""
|
|
32
|
-
import numpy as np
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
def _mobius_B(x, y):
|
|
36
|
-
"""Book Ch.5.3's Möbius map, bounded to [-1,1] -- a sign-safe
|
|
37
|
-
alternative to raw Newton division for landscapes whose curvature
|
|
38
|
-
isn't reliably one-signed (e.g. QAOA, empirically)."""
|
|
39
|
-
denom = abs(x) + abs(y)
|
|
40
|
-
if denom < 1e-12:
|
|
41
|
-
return 0.0
|
|
42
|
-
sgn = 1.0 if x >= 0 else -1.0
|
|
43
|
-
return y * sgn / denom
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
class SoftOpt:
|
|
47
|
-
def __init__(self, n_params, g_delta, lr=0.02, betas=(0.9, 0.999), eps=1e-8,
|
|
48
|
-
mode="newton", auto_detect_steps=30, auto_detect_threshold=0.8,
|
|
49
|
-
newton_lo=-0.5, newton_hi=0.5, eta_fallback=0.3,
|
|
50
|
-
eta_mobius=0.3, h_fd=1e-4, seed=None,
|
|
51
|
-
_test_frozen_constant=1.0):
|
|
52
|
-
self.g_delta = g_delta
|
|
53
|
-
self.lr = lr
|
|
54
|
-
self.b1, self.b2 = betas
|
|
55
|
-
self.eps = eps
|
|
56
|
-
if mode not in ("newton", "mobius", "auto"):
|
|
57
|
-
raise ValueError("mode must be 'newton', 'mobius', or 'auto'")
|
|
58
|
-
self.mode = mode
|
|
59
|
-
self.auto_detect_steps = auto_detect_steps
|
|
60
|
-
self.auto_detect_threshold = auto_detect_threshold
|
|
61
|
-
self._auto_pending = (mode == "auto")
|
|
62
|
-
self.detected_mode = None if mode == "auto" else mode
|
|
63
|
-
self.newton_lo = newton_lo
|
|
64
|
-
self.newton_hi = newton_hi
|
|
65
|
-
self.eta_fallback = eta_fallback
|
|
66
|
-
self.eta_mobius = eta_mobius
|
|
67
|
-
self.h_fd = h_fd
|
|
68
|
-
self.m = None
|
|
69
|
-
self.v = None
|
|
70
|
-
self.t = 0
|
|
71
|
-
self.rng = np.random.default_rng(seed)
|
|
72
|
-
self._test_frozen_constant = _test_frozen_constant
|
|
73
|
-
|
|
74
|
-
def _diagnose_mode(self, theta0):
|
|
75
|
-
"""Run once, before any optimization step: sample D2's sign
|
|
76
|
-
across several nearby points (not just theta0 itself -- a single
|
|
77
|
-
point's local curvature sign is not always representative) and
|
|
78
|
-
several random directions per point, matching the methodology
|
|
79
|
-
that correctly distinguished GRAPE/VQE's one-signed curvature
|
|
80
|
-
from QAOA's sign-indefinite curvature."""
|
|
81
|
-
n = theta0.shape[0]
|
|
82
|
-
n_points = 5
|
|
83
|
-
dirs_per_point = max(1, self.auto_detect_steps // n_points)
|
|
84
|
-
signs = []
|
|
85
|
-
for p in range(n_points):
|
|
86
|
-
point = theta0 if p == 0 else theta0 + self.rng.normal(scale=0.1, size=n)
|
|
87
|
-
for _ in range(dirs_per_point):
|
|
88
|
-
delta = self.rng.choice([-1.0, 1.0], size=n)
|
|
89
|
-
gp = self.g_delta(point + self.h_fd * delta, delta)
|
|
90
|
-
gm = self.g_delta(point - self.h_fd * delta, delta)
|
|
91
|
-
D2 = (gp - gm) / (2 * self.h_fd)
|
|
92
|
-
signs.append(1.0 if D2 >= 0 else -1.0)
|
|
93
|
-
signs = np.array(signs)
|
|
94
|
-
agreement = max(np.mean(signs > 0), np.mean(signs < 0))
|
|
95
|
-
self.detected_mode = "newton" if agreement >= self.auto_detect_threshold else "mobius"
|
|
96
|
-
self.mode = self.detected_mode
|
|
97
|
-
|
|
98
|
-
def step(self, theta, grad_estimate, _test_magnitude_source=None, _test_foreign_sampler=None, _test_rng=None):
|
|
99
|
-
"""One complete optimizer step: Adam base update + Newton-style
|
|
100
|
-
correction from the known model, in a single call.
|
|
101
|
-
theta: current parameters (np.ndarray)
|
|
102
|
-
grad_estimate: a gradient estimate of the loss w.r.t. theta (e.g.
|
|
103
|
-
from SPSA or backprop) -- exactly what you'd normally hand
|
|
104
|
-
to Adam.
|
|
105
|
-
Returns: theta for the next step.
|
|
106
|
-
|
|
107
|
-
The _test_* arguments are TEST-ONLY hooks used exclusively by the
|
|
108
|
-
causal-validation ablation harness to deliberately corrupt the
|
|
109
|
-
correction (feed a frozen constant or a foreign point's curvature
|
|
110
|
-
instead of the genuine one) so that "does real content matter"
|
|
111
|
-
can be checked against the EXACT SAME code path real usage takes.
|
|
112
|
-
Normal usage never sets these; leaving them unset gives the
|
|
113
|
-
genuine, real behavior.
|
|
114
|
-
"""
|
|
115
|
-
theta = np.asarray(theta, dtype=float)
|
|
116
|
-
grad_estimate = np.asarray(grad_estimate, dtype=float)
|
|
117
|
-
|
|
118
|
-
if self._auto_pending:
|
|
119
|
-
self._diagnose_mode(theta)
|
|
120
|
-
self._auto_pending = False
|
|
121
|
-
|
|
122
|
-
# --- Adam base update ---
|
|
123
|
-
self.t += 1
|
|
124
|
-
if self.m is None:
|
|
125
|
-
self.m = np.zeros_like(theta)
|
|
126
|
-
self.v = np.zeros_like(theta)
|
|
127
|
-
self.m = self.b1 * self.m + (1 - self.b1) * grad_estimate
|
|
128
|
-
self.v = self.b2 * self.v + (1 - self.b2) * grad_estimate ** 2
|
|
129
|
-
mh = self.m / (1 - self.b1 ** self.t)
|
|
130
|
-
vh = self.v / (1 - self.b2 ** self.t)
|
|
131
|
-
theta_after_adam = theta - self.lr * mh / (np.sqrt(vh) + self.eps)
|
|
132
|
-
|
|
133
|
-
if _test_magnitude_source == 'plain':
|
|
134
|
-
return theta_after_adam
|
|
135
|
-
|
|
136
|
-
# --- correction from the known, exact model ---
|
|
137
|
-
n = theta.shape[0]
|
|
138
|
-
gen_rng = _test_rng if _test_rng is not None else self.rng
|
|
139
|
-
delta = gen_rng.choice([-1.0, 1.0], size=n)
|
|
140
|
-
D1 = self.g_delta(theta_after_adam, delta)
|
|
141
|
-
gp = self.g_delta(theta_after_adam + self.h_fd * delta, delta)
|
|
142
|
-
gm = self.g_delta(theta_after_adam - self.h_fd * delta, delta)
|
|
143
|
-
D2_real = (gp - gm) / (2 * self.h_fd)
|
|
144
|
-
|
|
145
|
-
if _test_magnitude_source in (None, 'real'):
|
|
146
|
-
D2_used = D2_real
|
|
147
|
-
elif _test_magnitude_source == 'frozen':
|
|
148
|
-
D2_used = self._test_frozen_constant
|
|
149
|
-
elif _test_magnitude_source in ('foreign_point_and_dir', 'foreign_point_same_dir'):
|
|
150
|
-
theta_f = _test_foreign_sampler(gen_rng)
|
|
151
|
-
delta_f = gen_rng.choice([-1.0, 1.0], size=n) if _test_magnitude_source == 'foreign_point_and_dir' else delta
|
|
152
|
-
gp_f = self.g_delta(theta_f + self.h_fd * delta_f, delta_f)
|
|
153
|
-
gm_f = self.g_delta(theta_f - self.h_fd * delta_f, delta_f)
|
|
154
|
-
D2_used = abs((gp_f - gm_f) / (2 * self.h_fd))
|
|
155
|
-
else:
|
|
156
|
-
raise ValueError(_test_magnitude_source)
|
|
157
|
-
|
|
158
|
-
if self.mode == "mobius":
|
|
159
|
-
B = _mobius_B(D1, D2_used)
|
|
160
|
-
t_star = self.eta_mobius * B
|
|
161
|
-
else:
|
|
162
|
-
activate = D2_real > 1e-8
|
|
163
|
-
if activate and D2_used > 1e-9:
|
|
164
|
-
t_star = float(np.clip(-D1 / D2_used, self.newton_lo, self.newton_hi))
|
|
165
|
-
else:
|
|
166
|
-
t_star = float(np.clip(-self.eta_fallback * D1, self.newton_lo, self.newton_hi))
|
|
167
|
-
|
|
168
|
-
return theta_after_adam + t_star * delta
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|