softopt 0.7.0__tar.gz → 0.7.3__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 (26) hide show
  1. {softopt-0.7.0/softopt.egg-info → softopt-0.7.3}/PKG-INFO +1 -1
  2. {softopt-0.7.0 → softopt-0.7.3}/pyproject.toml +1 -1
  3. {softopt-0.7.0 → softopt-0.7.3}/softopt/__init__.py +6 -2
  4. softopt-0.7.3/softopt/_torch_compile.py +115 -0
  5. {softopt-0.7.0 → softopt-0.7.3}/softopt/calibrate.py +14 -3
  6. {softopt-0.7.0 → softopt-0.7.3/softopt.egg-info}/PKG-INFO +1 -1
  7. {softopt-0.7.0 → softopt-0.7.3}/softopt.egg-info/SOURCES.txt +3 -1
  8. softopt-0.7.3/tests/test_torch_compile.py +97 -0
  9. {softopt-0.7.0 → softopt-0.7.3}/LICENSE +0 -0
  10. {softopt-0.7.0 → softopt-0.7.3}/README.md +0 -0
  11. {softopt-0.7.0 → softopt-0.7.3}/setup.cfg +0 -0
  12. {softopt-0.7.0 → softopt-0.7.3}/softopt/compile.py +0 -0
  13. {softopt-0.7.0 → softopt-0.7.3}/softopt/numpy_optimizer.py +0 -0
  14. {softopt-0.7.0 → softopt-0.7.3}/softopt/soft_number.py +0 -0
  15. {softopt-0.7.0 → softopt-0.7.3}/softopt/torch_optimizer.py +0 -0
  16. {softopt-0.7.0 → softopt-0.7.3}/softopt.egg-info/dependency_links.txt +0 -0
  17. {softopt-0.7.0 → softopt-0.7.3}/softopt.egg-info/requires.txt +0 -0
  18. {softopt-0.7.0 → softopt-0.7.3}/softopt.egg-info/top_level.txt +0 -0
  19. {softopt-0.7.0 → softopt-0.7.3}/tests/test_basic.py +0 -0
  20. {softopt-0.7.0 → softopt-0.7.3}/tests/test_calibrate.py +0 -0
  21. {softopt-0.7.0 → softopt-0.7.3}/tests/test_causal.py +0 -0
  22. {softopt-0.7.0 → softopt-0.7.3}/tests/test_compile.py +0 -0
  23. {softopt-0.7.0 → softopt-0.7.3}/tests/test_exact_d2.py +0 -0
  24. {softopt-0.7.0 → softopt-0.7.3}/tests/test_measured_slope.py +0 -0
  25. {softopt-0.7.0 → softopt-0.7.3}/tests/test_soft_number.py +0 -0
  26. {softopt-0.7.0 → softopt-0.7.3}/tests/test_torch.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: softopt
3
- Version: 0.7.0
3
+ Version: 0.7.3
4
4
  Summary: An optimization layer that closes the gap between your model and the real system, built on Klein–Maimon soft-number calculus
5
5
  Author: Ido Angel
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "softopt"
7
- version = "0.7.0"
7
+ version = "0.7.3"
8
8
  description = "An optimization layer that closes the gap between your model and the real system, built on Klein–Maimon soft-number calculus"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -6,6 +6,7 @@ model (a simulator, a circuit, a physical law) and the real system you measure.
6
6
  from softopt import spsa_gradient # gradient, direction and measured slope from 2 readings
7
7
  from softopt import SoftOptTorch # PyTorch version (torch.optim.Optimizer)
8
8
  from softopt import soft_compile # turn a plain-Python model into g_delta
9
+ from softopt import torch_compile # the same for a PyTorch model (forward-mode = soft numbers)
9
10
  from softopt import SoftNumber # the algebraic primitive itself
10
11
 
11
12
  calibrate=True (new in 0.7.0): soft_compile(model, n_params, n_bias=k) for model(theta, beta), then
@@ -25,12 +26,15 @@ def __getattr__(name):
25
26
  if name == "SoftOptTorch":
26
27
  from .torch_optimizer import SoftOptTorch
27
28
  return SoftOptTorch
29
+ if name == "torch_compile":
30
+ from ._torch_compile import torch_compile
31
+ return torch_compile
28
32
  raise AttributeError(f"module 'softopt' has no attribute {name!r}")
29
33
 
30
34
 
31
35
  __all__ = [
32
- "SoftOpt", "SoftOptTorch", "SoftNumber", "soft_compile", "spsa_gradient",
36
+ "SoftOpt", "SoftOptTorch", "SoftNumber", "soft_compile", "torch_compile", "spsa_gradient",
33
37
  "sadd", "sneg", "ssub", "smul", "sinv", "sdiv",
34
38
  "spow", "sroot", "ssqrt", "ssin", "scos", "sexp", "slog",
35
39
  ]
36
- __version__ = "0.7.0"
40
+ __version__ = "0.7.3"
@@ -0,0 +1,115 @@
1
+ """
2
+ torch_compile: soft_compile for models written in PyTorch -- a neural network, a differentiable sampler, any
3
+ torch computation -- so SoftOpt (default and calibrate=True) can use them.
4
+
5
+ from softopt import SoftOpt, torch_compile
6
+
7
+ def model(theta, beta): # torch tensors in, a scalar tensor out
8
+ ...
9
+
10
+ g = torch_compile(model, n_params=n, n_bias=k)
11
+ opt = SoftOpt(n, g, lr=0.02, calibrate=True, bias_scale=0.1)
12
+
13
+ How the soft algebra is carried
14
+ A soft number a*eps + b (eps^2 = 0) is what PyTorch's forward-mode automatic differentiation propagates:
15
+ every value b travels with a tangent a, and every operation applies f(a eps + b) = a f'(b) eps + f(b)
16
+ (Lemma 6.1). torch.func.jvp is that single-axis soft evaluation; a jvp inside a jvp is the NESTED soft number
17
+ ((e, a), (c, v)) with two axes eps1, eps2:
18
+ eps1 and eps2 both along d -> v = f, c = a = D1, e = D2 (d1_d2)
19
+ eps1 along d, eps2 along beta_j -> e = d D1 / d beta_j (d1_and_jac: the mixed part)
20
+ d1_and_jac carries all beta_j axes in one vectorized pass (0.7.3); it falls back to one pass per beta_j for
21
+ models whose operations cannot be batched.
22
+ The same quantities softopt.calibrate.CompiledModel computes with SoftNumber objects, so the calibrator and
23
+ the optimizer are unchanged. Checked against CompiledModel in tests/test_torch_compile.py.
24
+ """
25
+ import numpy as np
26
+
27
+
28
+ def _torch():
29
+ import torch
30
+ from torch.func import jvp
31
+ return torch, jvp
32
+
33
+
34
+ class TorchModel:
35
+ """model_fn(theta, beta) (or model_fn(theta) when n_bias=0) on nested soft numbers via nested jvp."""
36
+
37
+ def __init__(self, model_fn, n_bias=0, dtype=None, device=None):
38
+ torch, _ = _torch()
39
+ self.f, self.nb = model_fn, int(n_bias)
40
+ self.dtype = torch.float64 if dtype is None else dtype
41
+ self.device = device
42
+ self._vectorized = True
43
+
44
+ def _t(self, v):
45
+ torch, _ = _torch()
46
+ return torch.as_tensor(np.asarray(v, dtype=float), dtype=self.dtype, device=self.device)
47
+
48
+ def _call(self, th, bs):
49
+ return self.f(th, bs) if self.nb else self.f(th)
50
+
51
+ def value(self, x, beta=()):
52
+ torch, _ = _torch()
53
+ with torch.no_grad():
54
+ return float(self._call(self._t(x), self._t(beta)))
55
+
56
+ def _d1(self, x, d, b):
57
+ _, jvp = _torch()
58
+ return jvp(lambda t: self._call(t, b), (x,), (d,))[1]
59
+
60
+ def d1_d2(self, x, d, beta=()):
61
+ """D1 and D2 along d: eps1 and eps2 both along d."""
62
+ _, jvp = _torch()
63
+ x, d, b = self._t(x), self._t(d), self._t(beta)
64
+ D1, D2 = jvp(lambda t: self._d1(t, d, b), (x,), (d,))
65
+ return float(D1), float(D2)
66
+
67
+ def d1_and_jac(self, x, d, beta=()):
68
+ """D1 along d, and d D1 / d beta_j for every j: eps1 along d, eps2 along beta_j (the mixed part)."""
69
+ torch, jvp = _torch()
70
+ x, d, b = self._t(x), self._t(d), self._t(beta)
71
+ if not self.nb:
72
+ return float(self._d1(x, d, b)), np.zeros(0)
73
+ if self._vectorized:
74
+ # all bias tangents in one pass: a soft number carrying one eps2 axis per bias parameter (vmap of the
75
+ # forward-mode slope over beta). Same numbers as the loop below, about nb times fewer passes.
76
+ try:
77
+ from torch.func import jacfwd
78
+ def slope(bb):
79
+ y = self._d1(x, d, bb)
80
+ return y, y
81
+ jac, D1 = jacfwd(slope, has_aux=True)(b)
82
+ return float(D1), jac.detach().cpu().numpy().astype(float).reshape(self.nb)
83
+ except Exception:
84
+ self._vectorized = False # a model op without batching support: use one pass per bias
85
+ jac, D1 = np.zeros(self.nb), None
86
+ for j in range(self.nb):
87
+ e = torch.zeros_like(b); e[j] = 1.0
88
+ D1, jac[j] = jvp(lambda bb: self._d1(x, d, bb), (b,), (e,))
89
+ return float(D1), jac
90
+
91
+
92
+ def torch_compile(model_fn, n_params=None, n_bias=0, dtype=None, device=None, verify=True):
93
+ """model_fn(theta) -> scalar tensor, or model_fn(theta, beta) with n_bias > 0 (beta = offsets of the
94
+ model's uncertain parameters, 0 = nominal). Returns g_delta for SoftOpt, carrying .d1_d2 (exact D2) and
95
+ .calibration (for calibrate=True). dtype defaults to float64; pass torch.float32 for large networks."""
96
+ M = TorchModel(model_fn, n_bias, dtype, device)
97
+ zero = np.zeros(M.nb)
98
+
99
+ def g_delta(theta, delta):
100
+ return M.d1_and_jac(theta, delta, zero)[0]
101
+
102
+ g_delta.d1_d2 = lambda theta, delta: M.d1_d2(theta, delta, zero)
103
+ g_delta.calibration = M
104
+ g_delta.value = lambda theta: M.value(theta, zero)
105
+ if verify and n_params:
106
+ rng = np.random.default_rng(0)
107
+ x, d = rng.normal(size=n_params), rng.choice([-1.0, 1.0], size=n_params)
108
+ h = 1e-4
109
+ fd = (M.value(x + h * d, zero) - M.value(x - h * d, zero)) / (2 * h)
110
+ D1 = g_delta(x, d)
111
+ tol = 1e-4 if M.dtype.itemsize == 8 else 1e-2
112
+ if not np.isfinite(D1) or abs(D1 - fd) > tol * max(1.0, abs(fd)):
113
+ raise ValueError(f'torch_compile: the forward-mode slope {D1:.6g} disagrees with finite differences '
114
+ f'{fd:.6g} -- the model may not be differentiable here, or it detaches theta')
115
+ return g_delta
@@ -28,6 +28,10 @@ Two ways to use it
28
28
  their expected size (a number or one per parameter), on the PHYSICAL scale -- a prior far too wide lets the
29
29
  filter fit noise.
30
30
 
31
+ The discrepancy dg enters the slope only as far as it stands out of its own uncertainty,
32
+ wg = (dg.e)^2 / ((dg.e)^2 + var(dg.e)) (new in 0.7.2): with noisy readings dg would otherwise absorb the noise
33
+ and cancel the model's soft-number slope.
34
+
31
35
  Validated (development + held-out seeds) where the bias structure is right: gate calibration, adaptive optics,
32
36
  H2 with coherent gate errors, robot sim-to-real. When the model lacks a major error type the median is still at
33
37
  or below Adam but single runs can be worse; when the model is already exact, calibrate=False is better.
@@ -135,8 +139,15 @@ class Calibrator:
135
139
  beta, dg = st[self.ib], st[self.ig]
136
140
  D1m, D2m = self.M.d1_d2(x, e, beta)
137
141
  _, jac_e = self.M.d1_and_jac(x, e, beta)
138
- D1 = D1m + dg @ e
139
- h = np.r_[jac_e, 0.0, e]
142
+ # dg is the part of the slope the model cannot explain. Under noisy readings it also absorbs the noise,
143
+ # so it enters only as far as it stands out of its own uncertainty (wg); the soft-number slope of the
144
+ # calibrated model is always kept.
145
+ dge = float(dg @ e)
146
+ hg = np.r_[np.zeros_like(jac_e), 0.0, e]
147
+ varg = max(float(hg @ P @ hg), 0.0)
148
+ wg = dge ** 2 / (dge ** 2 + varg) if (dge ** 2 + varg) > 0 else 0.0
149
+ D1 = D1m + wg * dge
150
+ h = np.r_[jac_e, 0.0, wg * e]
140
151
  var = max(float(h @ P @ h), 0.0)
141
152
  t = -D1 / D2m if D2m > 1e-8 else -eta_fallback * D1
142
153
  w = D1 ** 2 / (D1 ** 2 + var) if (D1 ** 2 + var) > 0 else 0.0
@@ -144,7 +155,7 @@ class Calibrator:
144
155
  if step != 0.0:
145
156
  f0 = self.M.value(x, beta)
146
157
  for _ in range(20):
147
- df = self.M.value(x + step * e, beta) - f0 + step * (dg @ e)
158
+ df = self.M.value(x + step * e, beta) - f0 + step * wg * dge
148
159
  if df < 0:
149
160
  break
150
161
  c = (df - D1 * step) / step ** 2
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: softopt
3
- Version: 0.7.0
3
+ Version: 0.7.3
4
4
  Summary: An optimization layer that closes the gap between your model and the real system, built on Klein–Maimon soft-number calculus
5
5
  Author: Ido Angel
6
6
  License: MIT
@@ -2,6 +2,7 @@ LICENSE
2
2
  README.md
3
3
  pyproject.toml
4
4
  softopt/__init__.py
5
+ softopt/_torch_compile.py
5
6
  softopt/calibrate.py
6
7
  softopt/compile.py
7
8
  softopt/numpy_optimizer.py
@@ -19,4 +20,5 @@ tests/test_compile.py
19
20
  tests/test_exact_d2.py
20
21
  tests/test_measured_slope.py
21
22
  tests/test_soft_number.py
22
- tests/test_torch.py
23
+ tests/test_torch.py
24
+ tests/test_torch_compile.py
@@ -0,0 +1,97 @@
1
+ """torch_compile (nested jvp) must give exactly what soft_compile (nested SoftNumber) gives."""
2
+ import numpy as np
3
+ import pytest
4
+
5
+ torch = pytest.importorskip("torch")
6
+
7
+ from softopt import SoftOpt, soft_compile, spsa_gradient # noqa: E402
8
+ from softopt import torch_compile # noqa: E402
9
+
10
+ N, K = 5, 3
11
+
12
+
13
+ def model_soft(theta, beta):
14
+ x = theta
15
+ s = x[0] * x[0] * (1 + beta[0]) + np.sin(x[1] - 0.3 * x[2]) * (2 + beta[1])
16
+ s = s + np.exp(x[3] * x[4] + beta[2] * x[0]) * 0.1 + x[2] * x[2] * x[2] / (2 + x[4] * x[4])
17
+ return s
18
+
19
+
20
+ def model_torch(theta, beta):
21
+ x = theta
22
+ s = x[0] * x[0] * (1 + beta[0]) + torch.sin(x[1] - 0.3 * x[2]) * (2 + beta[1])
23
+ s = s + torch.exp(x[3] * x[4] + beta[2] * x[0]) * 0.1 + x[2] ** 3 / (2 + x[4] * x[4])
24
+ return s
25
+
26
+
27
+ def test_matches_soft_compile():
28
+ gs = soft_compile(model_soft, n_params=N, n_bias=K)
29
+ gt = torch_compile(model_torch, n_params=N, n_bias=K)
30
+ rng = np.random.default_rng(1)
31
+ for _ in range(5):
32
+ x, d, b = rng.normal(size=N), rng.choice([-1.0, 1.0], size=N), 0.1 * rng.normal(size=K)
33
+ assert abs(gs(x, d) - gt(x, d)) < 1e-10
34
+ Ms, Mt = gs.calibration, gt.calibration
35
+ for a, c in zip(Ms.d1_d2(x, d, b), Mt.d1_d2(x, d, b)):
36
+ assert abs(a - c) < 1e-10
37
+ (D1s, Js), (D1t, Jt) = Ms.d1_and_jac(x, d, b), Mt.d1_and_jac(x, d, b)
38
+ assert abs(D1s - D1t) < 1e-10 and np.max(np.abs(Js - Jt)) < 1e-10
39
+ assert abs(Ms.value(x, b) - Mt.value(x, b)) < 1e-12
40
+
41
+
42
+ def test_calibrate_trajectory_identical():
43
+ """The whole calibrate=True optimisation is the same with either compiler."""
44
+ true_beta = np.array([0.2, -0.3, 0.1])
45
+
46
+ def measure(x, rng):
47
+ return model_torch(torch.tensor(x), torch.tensor(true_beta)).item() + 0.01 * rng.normal()
48
+
49
+ xs = []
50
+ for comp, fn in ((soft_compile, model_soft), (torch_compile, model_torch)):
51
+ g = comp(fn, n_params=N, n_bias=K)
52
+ opt = SoftOpt(N, g, lr=0.05, calibrate=True, bias_scale=0.3, seed=3)
53
+ x, rng = np.full(N, 0.4), np.random.default_rng(7)
54
+ for _ in range(25):
55
+ grad, d, slope = spsa_gradient(lambda t: measure(t, rng), x, c=0.1, rng=rng)
56
+ x = opt.step(x, grad, direction=d, measured_slope=slope)
57
+ xs.append(x)
58
+ assert np.max(np.abs(xs[0] - xs[1])) < 1e-8
59
+
60
+
61
+ def test_plain_model_and_exact_d2():
62
+ g = torch_compile(lambda t: (t ** 2).sum() + torch.cos(t[0]), n_params=3)
63
+ D1, D2 = g.d1_d2(np.array([0.5, 1.0, -1.0]), np.array([1.0, -1.0, 1.0]))
64
+ assert abs(D2 - (6 - np.cos(0.5))) < 1e-12
65
+ assert abs(D1 - (1.0 - 2.0 - 2.0 - np.sin(0.5))) < 1e-12
66
+
67
+
68
+ def test_detached_model_is_caught():
69
+ with pytest.raises(ValueError):
70
+ torch_compile(lambda t: torch.tensor(float(t.sum().detach())), n_params=2)
71
+
72
+
73
+ def test_vectorized_bias_jacobian_equals_loop():
74
+ """0.7.3: d1_and_jac carries all bias axes in one vectorized pass; it must equal one pass per bias."""
75
+ gv = torch_compile(model_torch, n_params=N, n_bias=K)
76
+ gl = torch_compile(model_torch, n_params=N, n_bias=K)
77
+ gl.calibration._vectorized = False
78
+ rng = np.random.default_rng(2)
79
+ for _ in range(5):
80
+ x, d, b = rng.normal(size=N), rng.choice([-1.0, 1.0], size=N), 0.1 * rng.normal(size=K)
81
+ (D1v, Jv), (D1l, Jl) = gv.calibration.d1_and_jac(x, d, b), gl.calibration.d1_and_jac(x, d, b)
82
+ assert gv.calibration._vectorized
83
+ assert abs(D1v - D1l) < 1e-12 and np.max(np.abs(Jv - Jl)) < 1e-12
84
+
85
+
86
+ def test_unbatchable_model_falls_back_to_loop():
87
+ """A model op that cannot be batched (here .item() on a bias) must fall back to the per-bias loop."""
88
+ def model_item(theta, beta):
89
+ if float(beta[0].detach()) > 1e9: # data-dependent Python branch: not vmap-able
90
+ return theta.sum()
91
+ return model_torch(theta, beta)
92
+ g = torch_compile(model_item, n_params=N, n_bias=K, verify=False)
93
+ ref = torch_compile(model_torch, n_params=N, n_bias=K)
94
+ rng = np.random.default_rng(3)
95
+ x, d, b = rng.normal(size=N), rng.choice([-1.0, 1.0], size=N), 0.1 * rng.normal(size=K)
96
+ (D1, J), (D1r, Jr) = g.calibration.d1_and_jac(x, d, b), ref.calibration.d1_and_jac(x, d, b)
97
+ assert abs(D1 - D1r) < 1e-12 and np.max(np.abs(J - Jr)) < 1e-12
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes