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 +10 -0
- qqn_torch/_flatten.py +53 -0
- qqn_torch/_vendor/__init__.py +1 -0
- qqn_torch/_vendor/strong_wolfe.py +159 -0
- qqn_torch/line_search/__init__.py +35 -0
- qqn_torch/line_search/armijo.py +121 -0
- qqn_torch/line_search/backtracking.py +48 -0
- qqn_torch/line_search/base.py +38 -0
- qqn_torch/line_search/fixed.py +24 -0
- qqn_torch/line_search/strong_wolfe.py +74 -0
- qqn_torch/optimizer.py +205 -0
- qqn_torch/oracles/__init__.py +30 -0
- qqn_torch/oracles/base.py +26 -0
- qqn_torch/oracles/lbfgs.py +79 -0
- qqn_torch/oracles/momentum.py +31 -0
- qqn_torch/oracles/secant.py +30 -0
- qqn_torch/path.py +25 -0
- qqn_torch/regions/__init__.py +37 -0
- qqn_torch/regions/base.py +20 -0
- qqn_torch/regions/box.py +27 -0
- qqn_torch/regions/identity.py +11 -0
- qqn_torch/regions/sequential.py +21 -0
- qqn_torch/regions/trust.py +45 -0
- qqn_torch-0.1.0.dist-info/METADATA +246 -0
- qqn_torch-0.1.0.dist-info/RECORD +27 -0
- qqn_torch-0.1.0.dist-info/WHEEL +4 -0
- qqn_torch-0.1.0.dist-info/licenses/LICENSE +201 -0
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)
|