qqn-torch 0.1.0__py3-none-any.whl

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.
qqn_torch/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ """QQN (Quadratic Quasi-Newton) optimizer for PyTorch.
2
+
3
+ A drop-in replacement for ``torch.optim.LBFGS`` that searches a quadratic
4
+ path blending the steepest-descent and quasi-Newton directions.
5
+ """
6
+
7
+ from qqn_torch.optimizer import QQN
8
+
9
+ __all__ = ["QQN"]
10
+ __version__ = "0.1.1"
qqn_torch/_flatten.py ADDED
@@ -0,0 +1,53 @@
1
+ """Flatten / unflatten helpers for a list of parameter tensors.
2
+
3
+ All math in QQN happens on a single 1-D vector. These helpers mirror the
4
+ way ``torch.optim.LBFGS`` gathers parameters and gradients.
5
+ """
6
+
7
+ from typing import List
8
+
9
+ import torch
10
+ from torch import Tensor
11
+
12
+
13
+ def gather_flat_grad(params: List[Tensor]) -> Tensor:
14
+ """Concatenate the gradients of ``params`` into one 1-D tensor.
15
+
16
+ Parameters whose ``.grad`` is ``None`` contribute zeros (matching the
17
+ convention used by ``torch.optim.LBFGS._gather_flat_grad``).
18
+ """
19
+ views = []
20
+ for p in params:
21
+ if p.grad is None:
22
+ view = p.new_zeros(p.numel())
23
+ elif p.grad.is_sparse:
24
+ view = p.grad.to_dense().view(-1)
25
+ else:
26
+ view = p.grad.view(-1)
27
+ views.append(view)
28
+ return torch.cat(views, dim=0)
29
+
30
+
31
+ def gather_flat_params(params: List[Tensor]) -> Tensor:
32
+ """Concatenate the current parameter values into one 1-D tensor."""
33
+ return torch.cat([p.detach().reshape(-1) for p in params], dim=0)
34
+
35
+
36
+ def add_flat_update(params: List[Tensor], update: Tensor, alpha: float = 1.0) -> None:
37
+ """In-place ``params += alpha * update`` for the flattened ``update``."""
38
+ offset = 0
39
+ for p in params:
40
+ numel = p.numel()
41
+ p.add_(update[offset : offset + numel].view_as(p), alpha=alpha)
42
+ offset += numel
43
+ assert offset == update.numel(), "update size mismatch"
44
+
45
+
46
+ def set_flat_params(params: List[Tensor], flat: Tensor) -> None:
47
+ """In-place overwrite of ``params`` from the flattened ``flat``."""
48
+ offset = 0
49
+ for p in params:
50
+ numel = p.numel()
51
+ p.copy_(flat[offset : offset + numel].view_as(p))
52
+ offset += numel
53
+ assert offset == flat.numel(), "flat size mismatch"
@@ -0,0 +1 @@
1
+ """Vendored third-party code. See individual modules for attribution."""
@@ -0,0 +1,159 @@
1
+ """Vendored copy of PyTorch's ``_strong_wolfe`` helper.
2
+
3
+ Source: ``torch/optim/lbfgs.py`` (PyTorch project, BSD-style license).
4
+ Reproduced here, lightly adapted, to avoid depending on a private symbol
5
+ that may change or move across PyTorch versions.
6
+
7
+ Original copyright (c) PyTorch Contributors. Licensed under the BSD license;
8
+ see the PyTorch LICENSE file. Attribution retained per Section 7 of
9
+ ``pytorch.plan.md``.
10
+ """
11
+
12
+ from typing import Callable, Tuple
13
+
14
+
15
+ def _cubic_interpolate(x1, f1, g1, x2, f2, g2, bounds=None):
16
+ if bounds is not None:
17
+ xmin_bound, xmax_bound = bounds
18
+ else:
19
+ xmin_bound, xmax_bound = (x1, x2) if x1 <= x2 else (x2, x1)
20
+ d1 = g1 + g2 - 3 * (f1 - f2) / (x1 - x2)
21
+ d2_square = d1**2 - g1 * g2
22
+ if d2_square >= 0:
23
+ d2 = d2_square**0.5
24
+ if x1 <= x2:
25
+ min_pos = x2 - (x2 - x1) * ((g2 + d2 - d1) / (g2 - g1 + 2 * d2))
26
+ else:
27
+ min_pos = x1 - (x1 - x2) * ((g1 + d2 - d1) / (g1 - g2 + 2 * d2))
28
+ return min(max(min_pos, xmin_bound), xmax_bound)
29
+ else:
30
+ return (xmin_bound + xmax_bound) / 2.0
31
+
32
+
33
+ def strong_wolfe(
34
+ obj_func: Callable[[float], Tuple[float, float]],
35
+ d_dot,
36
+ f0: float,
37
+ g0_dot_d: float,
38
+ c1: float = 1e-4,
39
+ c2: float = 0.9,
40
+ tolerance_change: float = 1e-9,
41
+ max_ls: int = 25,
42
+ t_init: float = 1.0,
43
+ ):
44
+ """One-dimensional strong-Wolfe search along a fixed direction.
45
+
46
+ ``obj_func(t)`` must return ``(f, gtd)`` where ``gtd`` is the directional
47
+ derivative ``<grad(x + t*dir), dir>`` at the probed point.
48
+
49
+ Returns ``(t, f, gtd, n_evals)``.
50
+ """
51
+ d_norm = d_dot
52
+ f_prev, g_prev = f0, g0_dot_d
53
+ t = t_init
54
+ t_prev = 0.0
55
+
56
+ f_new, g_new = obj_func(t)
57
+ ls_func_evals = 1
58
+ gtd_new = g_new
59
+ ls_iter = 0
60
+
61
+ # bracket phase
62
+ bracket: list = [0.0, t]
63
+ bracket_f: list = [f0, f_new]
64
+ bracket_g: list = [g0_dot_d, gtd_new]
65
+ bracketed = False
66
+ while ls_iter < max_ls:
67
+ if f_new > (f0 + c1 * t * g0_dot_d) or (ls_iter > 1 and f_new >= f_prev):
68
+ bracket = [t_prev, t]
69
+ bracket_f = [f_prev, f_new]
70
+ bracket_g = [g_prev, gtd_new]
71
+ bracketed = True
72
+ break
73
+ if abs(gtd_new) <= -c2 * g0_dot_d:
74
+ return t, f_new, gtd_new, ls_func_evals
75
+ if gtd_new >= 0:
76
+ bracket = [t_prev, t]
77
+ bracket_f = [f_prev, f_new]
78
+ bracket_g = [g_prev, gtd_new]
79
+ bracketed = True
80
+ break
81
+
82
+ min_step = t + 0.01 * (t - t_prev)
83
+ max_step = t * 10
84
+ tmp = t
85
+ t = _cubic_interpolate(
86
+ t_prev,
87
+ f_prev,
88
+ g_prev,
89
+ t,
90
+ f_new,
91
+ gtd_new,
92
+ bounds=(min_step, max_step),
93
+ )
94
+ t_prev = tmp
95
+ f_prev = f_new
96
+ g_prev = gtd_new
97
+ f_new, g_new = obj_func(t)
98
+ ls_func_evals += 1
99
+ gtd_new = g_new
100
+ ls_iter += 1
101
+
102
+ if not bracketed:
103
+ bracket = [0.0, t]
104
+ bracket_f = [f0, f_new]
105
+ bracket_g = [g0_dot_d, gtd_new]
106
+
107
+ # zoom phase
108
+ insuf_progress = False
109
+ low_pos, high_pos = (0, 1) if bracket_f[0] <= bracket_f[-1] else (1, 0)
110
+ while ls_iter < max_ls:
111
+ if abs(bracket[1] - bracket[0]) * d_norm < tolerance_change:
112
+ break
113
+ t = _cubic_interpolate(
114
+ bracket[0],
115
+ bracket_f[0],
116
+ bracket_g[0],
117
+ bracket[1],
118
+ bracket_f[1],
119
+ bracket_g[1],
120
+ )
121
+
122
+ eps = 0.1 * (max(bracket) - min(bracket))
123
+ if min(max(bracket) - t, t - min(bracket)) < eps:
124
+ if insuf_progress or t >= max(bracket) or t <= min(bracket):
125
+ if abs(t - max(bracket)) < abs(t - min(bracket)):
126
+ t = max(bracket) - eps
127
+ else:
128
+ t = min(bracket) + eps
129
+ insuf_progress = False
130
+ else:
131
+ insuf_progress = True
132
+ else:
133
+ insuf_progress = False
134
+
135
+ f_new, g_new = obj_func(t)
136
+ ls_func_evals += 1
137
+ gtd_new = g_new
138
+ ls_iter += 1
139
+
140
+ if f_new > (f0 + c1 * t * g0_dot_d) or f_new >= bracket_f[low_pos]:
141
+ bracket[high_pos] = t
142
+ bracket_f[high_pos] = f_new
143
+ bracket_g[high_pos] = gtd_new
144
+ low_pos, high_pos = (0, 1) if bracket_f[0] <= bracket_f[1] else (1, 0)
145
+ else:
146
+ if abs(gtd_new) <= -c2 * g0_dot_d:
147
+ break
148
+ elif gtd_new * (bracket[high_pos] - bracket[low_pos]) >= 0:
149
+ bracket[high_pos] = bracket[low_pos]
150
+ bracket_f[high_pos] = bracket_f[low_pos]
151
+ bracket_g[high_pos] = bracket_g[low_pos]
152
+ bracket[low_pos] = t
153
+ bracket_f[low_pos] = f_new
154
+ bracket_g[low_pos] = gtd_new
155
+
156
+ t = bracket[low_pos]
157
+ f_new = bracket_f[low_pos]
158
+ gtd_new = bracket_g[low_pos]
159
+ return t, f_new, gtd_new, ls_func_evals
@@ -0,0 +1,35 @@
1
+ """Line-search registry: name -> LineSearch factory."""
2
+
3
+ from typing import Union
4
+
5
+ from qqn_torch.line_search.armijo import ArmijoLineSearch
6
+ from qqn_torch.line_search.backtracking import BacktrackingLineSearch
7
+ from qqn_torch.line_search.base import LineSearch, SearchResult
8
+ from qqn_torch.line_search.fixed import FixedLineSearch
9
+ from qqn_torch.line_search.strong_wolfe import StrongWolfeLineSearch
10
+
11
+ __all__ = [
12
+ "LineSearch",
13
+ "SearchResult",
14
+ "ArmijoLineSearch",
15
+ "BacktrackingLineSearch",
16
+ "StrongWolfeLineSearch",
17
+ "FixedLineSearch",
18
+ "build_line_search",
19
+ ]
20
+
21
+
22
+ def build_line_search(spec: Union[str, LineSearch]) -> LineSearch:
23
+ """Resolve a string shortcut or pass through a ``LineSearch`` instance."""
24
+ if not isinstance(spec, str):
25
+ return spec
26
+ name = spec.lower()
27
+ if name == "armijo":
28
+ return ArmijoLineSearch()
29
+ if name == "backtracking":
30
+ return BacktrackingLineSearch()
31
+ if name == "strong_wolfe":
32
+ return StrongWolfeLineSearch()
33
+ if name == "fixed":
34
+ return FixedLineSearch()
35
+ raise ValueError(f"unknown line search: {spec!r}")
@@ -0,0 +1,121 @@
1
+ """Backtracking line search enforcing the Armijo sufficient-decrease rule.
2
+
3
+ Starting from ``t = t_init`` we reduce ``t`` until
4
+
5
+ f(x + d(t)) <= f0 + c1 * t * <g, d'(0)>
6
+
7
+ holds, or ``max_iter`` is exhausted. Because ``d'(0) = -g``, the directional
8
+ derivative at the origin ``g0_dot_d0 = <g, d'(0)> = -||g||^2 <= 0`` is the
9
+ slope used by the Armijo test, scaled by ``t`` as a proxy for arc length.
10
+
11
+ Rather than fixed geometric contraction we use a safeguarded *quadratic*
12
+ interpolation of the observed function values, which typically reduces the
13
+ number of objective evaluations dramatically on the quadratic path. We also
14
+ permit an initial *expansion* phase: because the QQN path is quadratic in
15
+ ``t`` and anchored to the (often well-scaled) quasi-Newton endpoint at
16
+ ``t = 1``, the best step frequently lies at or beyond ``t = 1``.
17
+ """
18
+
19
+ from typing import Optional
20
+
21
+ from qqn_torch.line_search.base import EvalFn, SearchResult
22
+ from qqn_torch.path import path_point
23
+
24
+
25
+ class ArmijoLineSearch:
26
+ def search(
27
+ self,
28
+ evaluate: EvalFn,
29
+ f0: float,
30
+ g0_dot_d0: float,
31
+ grad_dir,
32
+ qn_dir,
33
+ options: Optional[dict],
34
+ ) -> SearchResult:
35
+ opts = options or {}
36
+ c1 = opts.get("c1", 1e-4)
37
+ shrink = opts.get("shrink", 0.5)
38
+ t_init = opts.get("t_init", 1.0)
39
+ max_iter = opts.get("max_iter", 25)
40
+ # Whether to try growing past t_init when t_init already satisfies
41
+ # Armijo (the quadratic path often has its best point at t >= 1).
42
+ allow_expand = opts.get("allow_expand", True)
43
+ grow = opts.get("grow", 2.0)
44
+ t_max = opts.get("t_max", 16.0)
45
+ # Lower clamp on interpolation contraction to avoid tiny steps.
46
+ min_shrink = opts.get("min_shrink", 0.1)
47
+ max_shrink = opts.get("max_shrink", 0.5)
48
+
49
+ def armijo_ok(t: float, f_t: float) -> bool:
50
+ return f_t <= f0 + c1 * t * g0_dot_d0
51
+
52
+ n_evals = 0
53
+ best = None # (t, f, g): best (lowest f) probe seen overall
54
+
55
+ def record(t, f_t, g_t):
56
+ nonlocal best
57
+ if best is None or f_t < best[1]:
58
+ best = (t, f_t, g_t)
59
+
60
+ # --- Evaluate the initial step ---
61
+ t = t_init
62
+ f_t, g_t = evaluate(t)
63
+ n_evals += 1
64
+ record(t, f_t, g_t)
65
+
66
+ if armijo_ok(t, f_t):
67
+ # Optionally try to expand: keep growing while Armijo holds and
68
+ # the function keeps decreasing. This lets the quadratic path
69
+ # reach its natural (often super-unit) optimum.
70
+ if allow_expand:
71
+ best_t, best_f, best_g = t, f_t, g_t
72
+ t_e = t
73
+ while t_e * grow <= t_max and n_evals < max_iter:
74
+ t_e = t_e * grow
75
+ f_e, g_e = evaluate(t_e)
76
+ n_evals += 1
77
+ record(t_e, f_e, g_e)
78
+ if armijo_ok(t_e, f_e) and f_e < best_f:
79
+ best_t, best_f, best_g = t_e, f_e, g_e
80
+ else:
81
+ break
82
+ update = path_point(best_t, grad_dir, qn_dir)
83
+ return SearchResult(best_t, best_f, best_g, update, n_evals, True)
84
+ update = path_point(t, grad_dir, qn_dir)
85
+ return SearchResult(t, f_t, g_t, update, n_evals, True)
86
+
87
+ # --- Backtracking with safeguarded quadratic interpolation ---
88
+ # We have f(0) = f0, f'(0) = g0_dot_d0, and f(t) = f_t.
89
+ # The quadratic q(s) matching these has its minimizer at
90
+ # s* = -g0_dot_d0 * t^2 / (2 * (f_t - f0 - g0_dot_d0 * t)).
91
+ t_prev = t
92
+ f_prev = f_t
93
+ while n_evals < max_iter:
94
+ denom = 2.0 * (f_prev - f0 - g0_dot_d0 * t_prev)
95
+ if denom > 0:
96
+ t_quad = -g0_dot_d0 * t_prev * t_prev / denom
97
+ # Safeguard the interpolated step into a sane contraction
98
+ # band relative to the previous t.
99
+ lo = min_shrink * t_prev
100
+ hi = max_shrink * t_prev
101
+ t = min(max(t_quad, lo), hi)
102
+ else:
103
+ t = shrink * t_prev
104
+
105
+ f_t, g_t = evaluate(t)
106
+ n_evals += 1
107
+ record(t, f_t, g_t)
108
+
109
+ if armijo_ok(t, f_t):
110
+ update = path_point(t, grad_dir, qn_dir)
111
+ return SearchResult(t, f_t, g_t, update, n_evals, True)
112
+
113
+ t_prev = t
114
+ f_prev = f_t
115
+
116
+ # No Armijo-acceptable step found; return the best probe so the
117
+ # caller can still make (possibly non-decreasing) progress / stop.
118
+ assert best is not None
119
+ t_b, f_b, g_b = best
120
+ update = path_point(t_b, grad_dir, qn_dir)
121
+ return SearchResult(t_b, f_b, g_b, update, n_evals, f_b < f0)
@@ -0,0 +1,48 @@
1
+ """Plain backtracking line search (Armijo with fixed geometric contraction).
2
+
3
+ Unlike :class:`ArmijoLineSearch` (which uses safeguarded quadratic
4
+ interpolation and an expansion phase), this strategy performs simple
5
+ geometric backtracking from ``t = t_init`` with a fixed ``shrink`` factor.
6
+ Kept as a minimal, predictable baseline for benchmarking and as the
7
+ explicit ``"backtracking"`` registry entry, matching the JAX reference.
8
+ """
9
+
10
+ from typing import Optional
11
+
12
+ from qqn_torch.line_search.base import EvalFn, SearchResult
13
+ from qqn_torch.path import path_point
14
+
15
+
16
+ class BacktrackingLineSearch:
17
+ def search(
18
+ self,
19
+ evaluate: EvalFn,
20
+ f0: float,
21
+ g0_dot_d0: float,
22
+ grad_dir,
23
+ qn_dir,
24
+ options: Optional[dict],
25
+ ) -> SearchResult:
26
+ opts = options or {}
27
+ c1 = opts.get("c1", 1e-4)
28
+ shrink = opts.get("shrink", 0.5)
29
+ t_init = opts.get("t_init", 1.0)
30
+ max_iter = opts.get("max_iter", 25)
31
+
32
+ t = t_init
33
+ n_evals = 0
34
+ best = None
35
+ for _ in range(max_iter):
36
+ f_t, g_t = evaluate(t)
37
+ n_evals += 1
38
+ if f_t <= f0 + c1 * t * g0_dot_d0:
39
+ update = path_point(t, grad_dir, qn_dir)
40
+ return SearchResult(t, f_t, g_t, update, n_evals, True)
41
+ if best is None or f_t < best[1]:
42
+ best = (t, f_t, g_t)
43
+ t *= shrink
44
+
45
+ assert best is not None
46
+ t_b, f_b, g_b = best
47
+ update = path_point(t_b, grad_dir, qn_dir)
48
+ return SearchResult(t_b, f_b, g_b, update, n_evals, f_b < f0)
@@ -0,0 +1,38 @@
1
+ """LineSearch protocol and the shared result container.
2
+
3
+ A line search traverses the quadratic path ``d(t)`` over ``t in [0, 1]``,
4
+ evaluating candidate *states* ``x + d(t)`` and selecting the ``t`` (and step)
5
+ that satisfies a sufficient-decrease condition.
6
+ """
7
+
8
+ from dataclasses import dataclass
9
+ from typing import Callable, Optional, Protocol, runtime_checkable
10
+
11
+ from torch import Tensor
12
+
13
+
14
+ @dataclass
15
+ class SearchResult:
16
+ t_star: float # accepted path parameter
17
+ f_new: float # objective value at the accepted state
18
+ g_new: Tensor # flat gradient at the accepted state
19
+ update: Tensor # d(t_star): displacement from x
20
+ n_evals: int # number of objective evaluations consumed
21
+ success: bool # whether sufficient decrease was achieved
22
+
23
+
24
+ # ``evaluate(t)`` returns (f, flat_grad) at the state x + d(t).
25
+ EvalFn = Callable[[float], "tuple[float, Tensor]"]
26
+
27
+
28
+ @runtime_checkable
29
+ class LineSearch(Protocol):
30
+ def search(
31
+ self,
32
+ evaluate: EvalFn,
33
+ f0: float,
34
+ g0_dot_d0: float,
35
+ grad_dir: Tensor,
36
+ qn_dir: Tensor,
37
+ options: Optional[dict],
38
+ ) -> SearchResult: ...
@@ -0,0 +1,24 @@
1
+ """Fixed-step "line search": always take t = t_fixed. Debug/baseline only."""
2
+
3
+ from typing import Optional
4
+
5
+ from qqn_torch.line_search.base import EvalFn, SearchResult
6
+
7
+
8
+ class FixedLineSearch:
9
+ def search(
10
+ self,
11
+ evaluate: EvalFn,
12
+ f0: float,
13
+ g0_dot_d0: float,
14
+ grad_dir,
15
+ qn_dir,
16
+ options: Optional[dict],
17
+ ) -> SearchResult:
18
+ opts = options or {}
19
+ t = opts.get("t_fixed", 1.0)
20
+ f_t, g_t = evaluate(t)
21
+ from qqn_torch.path import path_point
22
+
23
+ update = path_point(t, grad_dir, qn_dir)
24
+ return SearchResult(t, f_t, g_t, update, 1, f_t < f0)
@@ -0,0 +1,74 @@
1
+ """Strong-Wolfe line search wrapping the vendored ``strong_wolfe`` helper.
2
+
3
+ The path is non-linear in ``t``, so the directional derivative along the
4
+ path is ``<grad(x + d(t)), d'(t)>`` (not a constant direction). We feed the
5
+ vendored 1-D search this state-dependent slope, which keeps the L-BFGS
6
+ curvature updates well-conditioned.
7
+
8
+ Note (per algorithm.md): strong-Wolfe can *over-restrict* the quadratic-path
9
+ step on some problems; it is offered but not the default.
10
+ """
11
+
12
+ from typing import Optional
13
+
14
+ import torch
15
+
16
+ from qqn_torch.line_search.base import EvalFn, SearchResult
17
+ from qqn_torch.path import path_derivative, path_point
18
+ from qqn_torch._vendor.strong_wolfe import strong_wolfe
19
+
20
+
21
+ class StrongWolfeLineSearch:
22
+ def search(
23
+ self,
24
+ evaluate: EvalFn,
25
+ f0: float,
26
+ g0_dot_d0: float,
27
+ grad_dir,
28
+ qn_dir,
29
+ options: Optional[dict],
30
+ ) -> SearchResult:
31
+ opts = options or {}
32
+ c1 = opts.get("c1", 1e-4)
33
+ c2 = opts.get("c2", 0.9)
34
+ max_ls = opts.get("max_iter", 25)
35
+ tol_change = opts.get("tolerance_change", 1e-9)
36
+
37
+ # Cache the most recent full-gradient evaluation so we can recover
38
+ # the accepted gradient without an extra objective call.
39
+ cache: dict = {"t": None, "f": None, "g": None}
40
+
41
+ def obj_func(t):
42
+ f_t, g_t = evaluate(t)
43
+ cache["t"] = t
44
+ cache["f"] = f_t
45
+ cache["g"] = g_t
46
+ # Directional derivative along the (curved) path: <g, d'(t)>.
47
+ dt = path_derivative(t, grad_dir, qn_dir)
48
+ gtd = float(torch.dot(g_t, dt))
49
+ return f_t, gtd
50
+
51
+ # ``d_dot`` scales the bracket tolerance; use the path tangent norm.
52
+ d0 = path_derivative(0.0, grad_dir, qn_dir)
53
+ d_norm = float(d0.abs().max())
54
+
55
+ t, f_new, _gtd, n_evals = strong_wolfe(
56
+ obj_func,
57
+ d_norm,
58
+ f0,
59
+ g0_dot_d0,
60
+ c1=c1,
61
+ c2=c2,
62
+ tolerance_change=tol_change,
63
+ max_ls=max_ls,
64
+ )
65
+
66
+ # Recover the gradient at the accepted t. Reuse the cached value if
67
+ # the last probe matches; otherwise evaluate once more.
68
+ if cache["t"] == t and cache["g"] is not None:
69
+ f_acc, g_acc = cache["f"], cache["g"]
70
+ else:
71
+ f_acc, g_acc = evaluate(t)
72
+ n_evals += 1
73
+ update = path_point(t, grad_dir, qn_dir)
74
+ return SearchResult(t, f_acc, g_acc, update, n_evals, f_acc < f0)