spacecore-optimize 0.0.1__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.
- spacecore_optimize/__init__.py +53 -0
- spacecore_optimize/_common.py +69 -0
- spacecore_optimize/_optax.py +461 -0
- spacecore_optimize/_scipy.py +216 -0
- spacecore_optimize/functionals/__init__.py +36 -0
- spacecore_optimize/functionals/_entropy.py +134 -0
- spacecore_optimize/functionals/_huber.py +77 -0
- spacecore_optimize/functionals/_least_squares.py +106 -0
- spacecore_optimize/functionals/_proximal.py +193 -0
- spacecore_optimize/functionals/_realified.py +166 -0
- spacecore_optimize/functionals/_spectral.py +226 -0
- spacecore_optimize/py.typed +0 -0
- spacecore_optimize-0.0.1.dist-info/METADATA +101 -0
- spacecore_optimize-0.0.1.dist-info/RECORD +17 -0
- spacecore_optimize-0.0.1.dist-info/WHEEL +5 -0
- spacecore_optimize-0.0.1.dist-info/licenses/LICENSE +201 -0
- spacecore_optimize-0.0.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Optimization on top of SpaceCore.
|
|
2
|
+
|
|
3
|
+
External optimizer adapters, plus the functional catalogue in
|
|
4
|
+
:mod:`spacecore_optimize.functionals` (re-exported here). The adapters are thin
|
|
5
|
+
functions that drive a mature external optimizer from a SpaceCore
|
|
6
|
+
:class:`~spacecore.Functional`. Each adapter evaluates the objective through
|
|
7
|
+
``F.value``, converts the metric (Riesz) gradient ``F.grad`` to a coordinate
|
|
8
|
+
gradient with ``X.riesz`` -- the central, geometry-aware handoff --
|
|
9
|
+
and marshals between SpaceCore elements and the optimizer's representation. The
|
|
10
|
+
external optimizer owns the loop, line search, and convergence.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from ._optax import OptaxResult, minimize_optax
|
|
16
|
+
from ._scipy import line_search_scipy, minimize_scipy
|
|
17
|
+
from .functionals import (
|
|
18
|
+
HuberFunctional,
|
|
19
|
+
KLDivergenceFunctional,
|
|
20
|
+
NegativeEntropyFunctional,
|
|
21
|
+
NuclearNormFunctional,
|
|
22
|
+
RealifiedFunctional,
|
|
23
|
+
SpectralFunctional,
|
|
24
|
+
eigenvalue_space,
|
|
25
|
+
generalized_shrinkage,
|
|
26
|
+
least_squares,
|
|
27
|
+
project_nonneg,
|
|
28
|
+
prox_l1,
|
|
29
|
+
prox_l2sq,
|
|
30
|
+
realify,
|
|
31
|
+
spectralize,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"HuberFunctional",
|
|
36
|
+
"KLDivergenceFunctional",
|
|
37
|
+
"NegativeEntropyFunctional",
|
|
38
|
+
"NuclearNormFunctional",
|
|
39
|
+
"OptaxResult",
|
|
40
|
+
"RealifiedFunctional",
|
|
41
|
+
"SpectralFunctional",
|
|
42
|
+
"eigenvalue_space",
|
|
43
|
+
"generalized_shrinkage",
|
|
44
|
+
"least_squares",
|
|
45
|
+
"line_search_scipy",
|
|
46
|
+
"minimize_optax",
|
|
47
|
+
"minimize_scipy",
|
|
48
|
+
"project_nonneg",
|
|
49
|
+
"prox_l1",
|
|
50
|
+
"prox_l2sq",
|
|
51
|
+
"realify",
|
|
52
|
+
"spectralize",
|
|
53
|
+
]
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Shared marshalling helpers for the external-optimizer adapters.
|
|
2
|
+
|
|
3
|
+
The adapters in this subpackage drive a mature external optimizer (SciPy, optax)
|
|
4
|
+
from a SpaceCore :class:`~spacecore.Functional`. They share one central
|
|
5
|
+
responsibility, stated here: a SpaceCore gradient ``F.grad(x)`` is a
|
|
6
|
+
*metric* (Riesz) gradient, but external NumPy/JAX optimizers consume
|
|
7
|
+
a *coordinate* gradient. The correct, geometry-aware handoff is
|
|
8
|
+
``X.riesz(F.grad(x))`` -- the identity on a Euclidean space, and mandatory on a
|
|
9
|
+
weighted/non-Euclidean one. :func:`coordinate_gradient` performs this conversion
|
|
10
|
+
once, centrally. Beck, *First-Order Methods in Optimization*, Remark 3.32.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from spacecore import Functional
|
|
17
|
+
from spacecore import InnerProductSpace
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def require_functional(F: Any, fname: str) -> Functional:
|
|
21
|
+
"""Return ``F`` as a :class:`~spacecore.Functional` or raise a clear type error."""
|
|
22
|
+
if not isinstance(F, Functional):
|
|
23
|
+
raise TypeError(
|
|
24
|
+
f"{fname} requires a spacecore Functional, got {type(F).__name__}."
|
|
25
|
+
)
|
|
26
|
+
return F
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def domain_with_geometry(F: Functional, fname: str) -> InnerProductSpace:
|
|
30
|
+
"""Return ``F.domain`` after checking it carries an inner-product geometry.
|
|
31
|
+
|
|
32
|
+
The metric-to-coordinate gradient handoff needs ``X.riesz``; a domain without
|
|
33
|
+
inner-product geometry has no Riesz map and no well-defined metric gradient
|
|
34
|
+
(the ``Functional`` contract), so the adapter refuses it rather than silently assuming Euclidean.
|
|
35
|
+
"""
|
|
36
|
+
X = F.domain
|
|
37
|
+
if not isinstance(X, InnerProductSpace):
|
|
38
|
+
raise TypeError(
|
|
39
|
+
f"{fname} requires F.domain to be an InnerProductSpace so the metric "
|
|
40
|
+
f"gradient can be converted to a coordinate gradient with X.riesz; "
|
|
41
|
+
f"got {type(X).__name__}."
|
|
42
|
+
)
|
|
43
|
+
return X
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def coordinate_gradient(F: Functional, X: InnerProductSpace, x: Any) -> Any:
|
|
47
|
+
"""Return the coordinate gradient ``F.differential(x)`` at ``x``.
|
|
48
|
+
|
|
49
|
+
This is the central conversion: an external coordinate-gradient consumer wants
|
|
50
|
+
the differential covector, which equals ``X.riesz(F.grad(x))``. Coordinate
|
|
51
|
+
functionals return it directly, without a Riesz-map round trip.
|
|
52
|
+
"""
|
|
53
|
+
return F.differential(x)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def require_real_field(X: InnerProductSpace, fname: str) -> None:
|
|
57
|
+
"""Raise when ``X`` is over the complex field.
|
|
58
|
+
|
|
59
|
+
The SciPy adapters drive real-valued optimizers (``scipy.optimize`` works on
|
|
60
|
+
real coordinate vectors). A complex domain must be split into real and
|
|
61
|
+
imaginary coordinates by the caller; refusing here is more honest than
|
|
62
|
+
silently dropping the imaginary part during ``asarray``.
|
|
63
|
+
"""
|
|
64
|
+
if X.field != "real":
|
|
65
|
+
raise ValueError(
|
|
66
|
+
f"{fname} drives a real-valued SciPy optimizer and cannot consume a "
|
|
67
|
+
f"complex domain ({type(X).__name__}, field={X.field!r}); split the "
|
|
68
|
+
f"problem into real and imaginary coordinates first."
|
|
69
|
+
)
|
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Compiled optax driver for SpaceCore functionals.
|
|
3
|
+
|
|
4
|
+
The loop runs in one ``jax.jit(jax.lax.while_loop(...))`` on the coordinate gradient
|
|
5
|
+
``X.riesz(F.grad(x))``. ``optax`` is imported lazily. Build the domain with
|
|
6
|
+
``check_level="none"`` so traced evaluations skip data-dependent checks.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from time import perf_counter
|
|
12
|
+
from typing import Any, Callable, NamedTuple
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
|
|
16
|
+
from spacecore import TreeSpace
|
|
17
|
+
from ._common import domain_with_geometry, require_functional
|
|
18
|
+
|
|
19
|
+
_ROW_FMT = "{i:>7d} {v:>+18.8e} {d:>+18.8e} {g:>18.3e}"
|
|
20
|
+
_HEADER = "{:>7} {:>18} {:>18} {:>18}".format("iter", "value", "delta", "grad_norm")
|
|
21
|
+
_RULE = "-" * len(_HEADER)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class _OptaxState(NamedTuple):
|
|
25
|
+
"""optax state plus statistics cached at ``params`` (computed once/iteration)."""
|
|
26
|
+
|
|
27
|
+
params: Any
|
|
28
|
+
opt_state: Any
|
|
29
|
+
value: Any
|
|
30
|
+
grad: Any # coordinate gradient X.riesz(F.grad)
|
|
31
|
+
grad_norm: Any
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class _History(NamedTuple):
|
|
35
|
+
"""Preallocated on-device history buffers, sliced to host after the loop."""
|
|
36
|
+
|
|
37
|
+
iteration: Any
|
|
38
|
+
value: Any
|
|
39
|
+
value_delta: Any
|
|
40
|
+
grad_norm: Any
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class _LoopState(NamedTuple):
|
|
44
|
+
"""``while_loop`` carry: counter, optimizer state, finiteness, history, ls-count."""
|
|
45
|
+
|
|
46
|
+
iteration: Any
|
|
47
|
+
state: _OptaxState
|
|
48
|
+
finite: Any
|
|
49
|
+
history: _History
|
|
50
|
+
history_index: Any
|
|
51
|
+
nls: Any # cumulative line-search steps
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _linesearch_steps(opt_state: Any) -> Any:
|
|
55
|
+
"""
|
|
56
|
+
Sum every ``num_linesearch_steps`` counter in an optax state, or ``None``.
|
|
57
|
+
|
|
58
|
+
Line-search optimizers expose ``num_linesearch_steps`` as *per-update*
|
|
59
|
+
line-search info in their state (backtracking reports ``search_state.iter_num``,
|
|
60
|
+
zoom reports ``final_state.count`` for the current round); gradient-transformation
|
|
61
|
+
optimizers (adam, sgd, ...) do not. The search is structural (walks NamedTuple
|
|
62
|
+
fields / tuples / dicts), and *every* counter found is summed, so a chain of
|
|
63
|
+
several line-search transforms is counted in full rather than only the first.
|
|
64
|
+
Returns ``None`` when no counter is present.
|
|
65
|
+
"""
|
|
66
|
+
# Walk the nested optax state; sum every num_linesearch_steps field found.
|
|
67
|
+
found = []
|
|
68
|
+
stack = [opt_state]
|
|
69
|
+
while stack:
|
|
70
|
+
node = stack.pop()
|
|
71
|
+
fields = getattr(node, "_fields", None)
|
|
72
|
+
if fields is not None: # NamedTuple
|
|
73
|
+
if "num_linesearch_steps" in fields:
|
|
74
|
+
found.append(node.num_linesearch_steps)
|
|
75
|
+
stack.extend(getattr(node, f) for f in fields)
|
|
76
|
+
elif isinstance(node, (tuple, list)):
|
|
77
|
+
stack.extend(node)
|
|
78
|
+
elif isinstance(node, dict):
|
|
79
|
+
stack.extend(node.values())
|
|
80
|
+
if not found:
|
|
81
|
+
return None
|
|
82
|
+
total = found[0]
|
|
83
|
+
for extra in found[1:]:
|
|
84
|
+
total = total + extra
|
|
85
|
+
return total
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _tree_all_finite(tree: Any) -> Any:
|
|
89
|
+
"""
|
|
90
|
+
Return ``True`` iff every leaf is entirely finite (real and imaginary parts).
|
|
91
|
+
|
|
92
|
+
Checks the leaves directly rather than relying on a scalar norm, so a single
|
|
93
|
+
non-finite gradient entry is caught even if it would not surface in the norm.
|
|
94
|
+
"""
|
|
95
|
+
import jax
|
|
96
|
+
import jax.numpy as jnp
|
|
97
|
+
|
|
98
|
+
leaves = jax.tree_util.tree_leaves(tree)
|
|
99
|
+
if not leaves:
|
|
100
|
+
return jnp.asarray(True)
|
|
101
|
+
return jnp.stack([jnp.all(jnp.isfinite(leaf)) for leaf in leaves]).all()
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclass
|
|
105
|
+
class OptaxResult:
|
|
106
|
+
"""
|
|
107
|
+
Result of :func:`minimize_optax`.
|
|
108
|
+
|
|
109
|
+
Parameters
|
|
110
|
+
----------
|
|
111
|
+
success : bool
|
|
112
|
+
Whether the run reached ``grad_norm <= tol`` and stayed finite.
|
|
113
|
+
status : int
|
|
114
|
+
``0`` converged, ``1`` iteration cap, ``2`` non-finite.
|
|
115
|
+
message : str
|
|
116
|
+
Readable status.
|
|
117
|
+
num_iters : int
|
|
118
|
+
Iterations executed.
|
|
119
|
+
nfev, njev : int
|
|
120
|
+
Evaluations by the driver itself: ``num_iters + 1`` each.
|
|
121
|
+
n_linesearch_steps : int
|
|
122
|
+
Line-search steps summed over the run; about one extra evaluation each.
|
|
123
|
+
final_value, final_grad_norm : float
|
|
124
|
+
Objective and dual gradient norm at the final point.
|
|
125
|
+
x_element : Any
|
|
126
|
+
The minimizer, an element of ``F.domain``.
|
|
127
|
+
history : dict
|
|
128
|
+
``iteration``, ``value``, ``value_delta`` and ``grad_norm`` arrays.
|
|
129
|
+
compile_seconds, execution_seconds, average_iteration_ms : float
|
|
130
|
+
Compile time, execution time, and mean time per iteration.
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
success: bool
|
|
134
|
+
status: int
|
|
135
|
+
message: str
|
|
136
|
+
num_iters: int
|
|
137
|
+
nfev: int
|
|
138
|
+
njev: int
|
|
139
|
+
n_linesearch_steps: int
|
|
140
|
+
final_value: float
|
|
141
|
+
final_grad_norm: float
|
|
142
|
+
x_element: Any
|
|
143
|
+
history: dict
|
|
144
|
+
compile_seconds: float
|
|
145
|
+
execution_seconds: float
|
|
146
|
+
average_iteration_ms: float
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def minimize_optax(
|
|
150
|
+
F: Any,
|
|
151
|
+
x0: Any,
|
|
152
|
+
opt: Any,
|
|
153
|
+
*,
|
|
154
|
+
max_iter: int = 1000,
|
|
155
|
+
tol: float = 1e-6,
|
|
156
|
+
project: Callable[[Any], Any] | None = None,
|
|
157
|
+
verbose: int = 1,
|
|
158
|
+
log_every: int = 50,
|
|
159
|
+
history_every: int | None = None,
|
|
160
|
+
record_history: bool = True,
|
|
161
|
+
) -> OptaxResult:
|
|
162
|
+
r"""
|
|
163
|
+
Minimize a functional with an optax optimizer inside one ``jax.jit`` while-loop.
|
|
164
|
+
|
|
165
|
+
Each iteration evaluates ``F.value_and_grad`` once and hands optax the coordinate
|
|
166
|
+
gradient ``X.riesz(F.grad(x))``. It stops when the dual norm of that gradient
|
|
167
|
+
(equal to ``X.norm(F.grad(x))``) is at most ``tol``.
|
|
168
|
+
|
|
169
|
+
Parameters
|
|
170
|
+
----------
|
|
171
|
+
F : Functional
|
|
172
|
+
Objective on a JAX-backed inner-product space, with ``grad`` implemented.
|
|
173
|
+
x0 : pytree
|
|
174
|
+
Initial parameters in ``F.domain``.
|
|
175
|
+
opt : optax.GradientTransformation
|
|
176
|
+
The optimizer, e.g. ``optax.adam(1e-2)`` or ``optax.lbfgs()``.
|
|
177
|
+
max_iter : int
|
|
178
|
+
Iteration cap.
|
|
179
|
+
tol : float
|
|
180
|
+
Tolerance on the dual norm of the gradient, measured in the domain geometry.
|
|
181
|
+
project : callable, optional
|
|
182
|
+
Map applied to the parameters after each update, e.g. a projection.
|
|
183
|
+
verbose : int
|
|
184
|
+
``0`` silent, ``1`` summary, ``2`` a row every ``log_every`` iterations.
|
|
185
|
+
log_every, history_every : int
|
|
186
|
+
Logging and history cadences; neither affects convergence.
|
|
187
|
+
record_history : bool
|
|
188
|
+
Whether to record value, value change and gradient norm.
|
|
189
|
+
|
|
190
|
+
Returns
|
|
191
|
+
-------
|
|
192
|
+
OptaxResult
|
|
193
|
+
Final statistics, the minimizer ``x_element``, the history and timings.
|
|
194
|
+
|
|
195
|
+
Raises
|
|
196
|
+
------
|
|
197
|
+
TypeError
|
|
198
|
+
If ``F`` is not a :class:`~spacecore.Functional`, or its domain has no inner
|
|
199
|
+
product or is not JAX-backed.
|
|
200
|
+
ValueError
|
|
201
|
+
If ``max_iter`` or ``tol`` is negative, or a cadence is not positive.
|
|
202
|
+
ImportError
|
|
203
|
+
If ``optax`` is not installed.
|
|
204
|
+
|
|
205
|
+
See Also
|
|
206
|
+
--------
|
|
207
|
+
spacecore_optimize.minimize_scipy : SciPy ``minimize`` with the same handoff.
|
|
208
|
+
|
|
209
|
+
Examples
|
|
210
|
+
--------
|
|
211
|
+
.. code-block:: python
|
|
212
|
+
|
|
213
|
+
import numpy as np
|
|
214
|
+
import optax
|
|
215
|
+
import spacecore as sc
|
|
216
|
+
import spacecore_optimize as spo
|
|
217
|
+
|
|
218
|
+
ctx = sc.Context(sc.JaxOps(), dtype=np.float32)
|
|
219
|
+
X = sc.DenseCoordinateSpace((2,), ctx, check_level="none")
|
|
220
|
+
Q = sc.DenseLinOp(ctx.asarray([[3.0, 0.0], [0.0, 1.0]]), X, X, ctx)
|
|
221
|
+
linear = sc.InnerProductFunctional(ctx.asarray([-3.0, -2.0]), X)
|
|
222
|
+
F = sc.LinOpQuadraticForm(Q, linear)
|
|
223
|
+
|
|
224
|
+
res = spo.minimize_optax(F, X.zeros(), optax.adam(1e-1), max_iter=1000, tol=1e-6)
|
|
225
|
+
# res.x_element is approximately (1.0, 2.0); res.success is True
|
|
226
|
+
"""
|
|
227
|
+
# Validate everything before importing optax and jax.
|
|
228
|
+
F = require_functional(F, "minimize_optax")
|
|
229
|
+
X = domain_with_geometry(F, "minimize_optax")
|
|
230
|
+
if getattr(X.ops, "family", None) != "jax":
|
|
231
|
+
raise TypeError(
|
|
232
|
+
"minimize_optax requires a JAX-backed domain (it compiles the loop "
|
|
233
|
+
f"with jax.jit); F.domain uses the {getattr(X.ops, 'family', '?')!r} "
|
|
234
|
+
"backend. Build the domain with a JaxOps context, or use minimize_scipy "
|
|
235
|
+
"for the NumPy backend."
|
|
236
|
+
)
|
|
237
|
+
max_iter = int(max_iter)
|
|
238
|
+
if max_iter < 0:
|
|
239
|
+
raise ValueError(f"max_iter must be non-negative, got {max_iter}.")
|
|
240
|
+
if tol < 0:
|
|
241
|
+
raise ValueError(f"tol must be non-negative, got {tol}.")
|
|
242
|
+
if log_every <= 0:
|
|
243
|
+
raise ValueError(f"log_every must be positive, got {log_every}.")
|
|
244
|
+
history_every = log_every if history_every is None else int(history_every)
|
|
245
|
+
if history_every <= 0:
|
|
246
|
+
raise ValueError(f"history_every must be positive, got {history_every}.")
|
|
247
|
+
|
|
248
|
+
try:
|
|
249
|
+
import optax
|
|
250
|
+
except ImportError as exc: # pragma: no cover - exercised only without optax
|
|
251
|
+
raise ImportError(
|
|
252
|
+
"minimize_optax requires the optional 'optax' dependency; "
|
|
253
|
+
"install it with `pip install spacecore[optax]`."
|
|
254
|
+
) from exc
|
|
255
|
+
import jax
|
|
256
|
+
import jax.numpy as jnp
|
|
257
|
+
|
|
258
|
+
verbose = int(verbose)
|
|
259
|
+
project_fn = (lambda p: p) if project is None else project
|
|
260
|
+
# Tree domains iterate on the canonical pytree of x0.
|
|
261
|
+
params = X.unflatten_tree(X.flatten_tree(x0)) if isinstance(X, TreeSpace) else x0
|
|
262
|
+
# Lets line-search optimizers receive value, grad and value_fn in update().
|
|
263
|
+
opt = optax.with_extra_args_support(opt)
|
|
264
|
+
|
|
265
|
+
def evaluate(p: Any):
|
|
266
|
+
value, mgrad = F.value_and_grad(p) # fused value + metric (Riesz) gradient
|
|
267
|
+
grad = X.riesz(mgrad) # metric -> coordinate gradient for optax
|
|
268
|
+
# Stop on the dual norm of the differential, which is the X-norm of the Riesz gradient.
|
|
269
|
+
return value, grad, X.ops.sqrt(X._real_inner_core(mgrad, mgrad))
|
|
270
|
+
|
|
271
|
+
def value_fn(p: Any): # objective for line-search optimizers (optax minimizes)
|
|
272
|
+
return F.value(p)
|
|
273
|
+
|
|
274
|
+
# One optimizer update, then projection, then a fused re-evaluation.
|
|
275
|
+
def step(state: _OptaxState) -> _OptaxState:
|
|
276
|
+
updates, opt_state = opt.update(
|
|
277
|
+
state.grad,
|
|
278
|
+
state.opt_state,
|
|
279
|
+
state.params,
|
|
280
|
+
value=state.value,
|
|
281
|
+
grad=state.grad,
|
|
282
|
+
value_fn=value_fn,
|
|
283
|
+
)
|
|
284
|
+
p = project_fn(optax.apply_updates(state.params, updates))
|
|
285
|
+
value, grad, grad_norm = evaluate(p) # exactly one value_and_grad per step
|
|
286
|
+
return _OptaxState(p, opt_state, value, grad, grad_norm)
|
|
287
|
+
|
|
288
|
+
# History rows: one per history_every steps, plus the first and the last.
|
|
289
|
+
value0, grad0, grad_norm0 = evaluate(params)
|
|
290
|
+
init_state = _OptaxState(params, opt.init(params), value0, grad0, grad_norm0)
|
|
291
|
+
real_dtype = jnp.asarray(jnp.real(value0)).dtype
|
|
292
|
+
capacity = max_iter // history_every + 2
|
|
293
|
+
|
|
294
|
+
def record(h: _History, index, iteration, value, delta, grad_norm) -> _History:
|
|
295
|
+
return _History(
|
|
296
|
+
iteration=h.iteration.at[index].set(jnp.asarray(iteration, jnp.int32)),
|
|
297
|
+
value=h.value.at[index].set(jnp.real(value)),
|
|
298
|
+
value_delta=h.value_delta.at[index].set(delta),
|
|
299
|
+
grad_norm=h.grad_norm.at[index].set(grad_norm),
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
def run_loop(init_state: _OptaxState, tol_val):
|
|
303
|
+
# Preallocated history keeps the loop body's shapes fixed, hence jittable.
|
|
304
|
+
zeros = jnp.zeros((capacity,), real_dtype)
|
|
305
|
+
history = _History(jnp.zeros((capacity,), jnp.int32), zeros, zeros, zeros)
|
|
306
|
+
finite0 = _tree_all_finite(
|
|
307
|
+
(init_state.value, init_state.grad)
|
|
308
|
+
) & jnp.isfinite(init_state.grad_norm)
|
|
309
|
+
if record_history:
|
|
310
|
+
history = record(
|
|
311
|
+
history, 0, 0, init_state.value, jnp.asarray(0.0, real_dtype),
|
|
312
|
+
init_state.grad_norm,
|
|
313
|
+
)
|
|
314
|
+
history_index = jnp.asarray(1, jnp.int32)
|
|
315
|
+
else:
|
|
316
|
+
history_index = jnp.asarray(0, jnp.int32)
|
|
317
|
+
loop0 = _LoopState(
|
|
318
|
+
jnp.asarray(0, jnp.int32), init_state, finite0, history, history_index,
|
|
319
|
+
jnp.asarray(0, jnp.int32),
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
# Continue while under max_iter, above tol, and finite.
|
|
323
|
+
def cond_fn(ls: _LoopState):
|
|
324
|
+
return (ls.iteration < max_iter) & (ls.state.grad_norm > tol_val) & ls.finite
|
|
325
|
+
|
|
326
|
+
def body_fn(ls: _LoopState):
|
|
327
|
+
# Step, count line-search steps, and test convergence and divergence.
|
|
328
|
+
prev_value = ls.state.value
|
|
329
|
+
next_state = step(ls.state)
|
|
330
|
+
iteration = ls.iteration + jnp.asarray(1, jnp.int32)
|
|
331
|
+
step_ls = _linesearch_steps(next_state.opt_state)
|
|
332
|
+
nls = ls.nls + (
|
|
333
|
+
jnp.asarray(0, jnp.int32)
|
|
334
|
+
if step_ls is None
|
|
335
|
+
else step_ls.astype(jnp.int32)
|
|
336
|
+
)
|
|
337
|
+
value = next_state.value
|
|
338
|
+
delta = jnp.real(value - prev_value)
|
|
339
|
+
grad_norm = next_state.grad_norm
|
|
340
|
+
finite = _tree_all_finite((value, next_state.grad)) & jnp.isfinite(grad_norm)
|
|
341
|
+
done_after = (iteration >= max_iter) | (grad_norm <= tol_val) | (~finite)
|
|
342
|
+
|
|
343
|
+
# Print from inside the compiled loop, on logging steps only.
|
|
344
|
+
if verbose >= 2:
|
|
345
|
+
log_now = ((iteration % log_every) == 0) | done_after
|
|
346
|
+
jax.lax.cond(
|
|
347
|
+
log_now,
|
|
348
|
+
lambda: jax.debug.print(
|
|
349
|
+
_ROW_FMT, i=iteration, v=jnp.real(value), d=delta,
|
|
350
|
+
g=grad_norm, ordered=True,
|
|
351
|
+
),
|
|
352
|
+
lambda: None,
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
# Write a history row on schedule; lax.cond keeps it traced.
|
|
356
|
+
if record_history:
|
|
357
|
+
do_record = ((iteration % history_every) == 0) | done_after
|
|
358
|
+
|
|
359
|
+
def write(args):
|
|
360
|
+
h, hi = args
|
|
361
|
+
return (
|
|
362
|
+
record(h, hi, iteration, value, delta, grad_norm),
|
|
363
|
+
hi + jnp.asarray(1, jnp.int32),
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
history, history_index = jax.lax.cond(
|
|
367
|
+
do_record, write, lambda a: a, (ls.history, ls.history_index)
|
|
368
|
+
)
|
|
369
|
+
else:
|
|
370
|
+
history, history_index = ls.history, ls.history_index
|
|
371
|
+
|
|
372
|
+
return _LoopState(iteration, next_state, finite, history, history_index, nls)
|
|
373
|
+
|
|
374
|
+
return jax.lax.while_loop(cond_fn, body_fn, loop0)
|
|
375
|
+
|
|
376
|
+
# Compile ahead of time so compile and execution are timed apart.
|
|
377
|
+
run = jax.jit(run_loop)
|
|
378
|
+
tol_arr = jnp.asarray(tol, real_dtype)
|
|
379
|
+
|
|
380
|
+
t0 = perf_counter()
|
|
381
|
+
compiled = run.lower(init_state, tol_arr).compile()
|
|
382
|
+
compile_seconds = perf_counter() - t0
|
|
383
|
+
|
|
384
|
+
if verbose >= 2:
|
|
385
|
+
print(_HEADER)
|
|
386
|
+
print(_RULE)
|
|
387
|
+
print(_ROW_FMT.format(i=0, v=float(np.real(value0)), d=0.0, g=float(grad_norm0)))
|
|
388
|
+
|
|
389
|
+
t1 = perf_counter()
|
|
390
|
+
result = jax.device_get(compiled(init_state, tol_arr))
|
|
391
|
+
execution_seconds = perf_counter() - t1
|
|
392
|
+
|
|
393
|
+
# Unpack the result on the host.
|
|
394
|
+
final = result.state
|
|
395
|
+
num_iters = int(result.iteration)
|
|
396
|
+
n_linesearch_steps = int(result.nls)
|
|
397
|
+
# Count only the value_and_grad calls the driver itself makes: one per
|
|
398
|
+
# iteration plus the initial evaluation. A line-search optimizer's internal
|
|
399
|
+
# evaluations are reported separately as n_linesearch_steps and are not folded
|
|
400
|
+
# in here (their exact count is optax-internal, and the driver does not reuse
|
|
401
|
+
# them via value_and_grad_from_state -- see the class docstring).
|
|
402
|
+
nfev = njev = num_iters + 1
|
|
403
|
+
final_value = float(np.real(final.value))
|
|
404
|
+
final_grad_norm = float(final.grad_norm)
|
|
405
|
+
# Return the minimizer as an element of F.domain (bound element for a
|
|
406
|
+
# structured space; the raw array is already an element otherwise).
|
|
407
|
+
x_element = X.element(final.params) if isinstance(X, TreeSpace) else final.params
|
|
408
|
+
# Status 2: diverged; 0: converged; 1: iteration cap.
|
|
409
|
+
finite = bool(np.asarray(result.finite))
|
|
410
|
+
success = finite and final_grad_norm <= tol
|
|
411
|
+
if not finite:
|
|
412
|
+
status, message = 2, "diverged: value or gradient became nonfinite"
|
|
413
|
+
elif success:
|
|
414
|
+
status, message = 0, "converged"
|
|
415
|
+
else:
|
|
416
|
+
status, message = 1, "maximum iterations reached"
|
|
417
|
+
|
|
418
|
+
# Trim the preallocated history to the rows actually written.
|
|
419
|
+
if record_history:
|
|
420
|
+
n = int(result.history_index)
|
|
421
|
+
history = {
|
|
422
|
+
"iteration": np.asarray(result.history.iteration[:n], dtype=int),
|
|
423
|
+
"value": np.asarray(result.history.value[:n], dtype=float),
|
|
424
|
+
"value_delta": np.asarray(result.history.value_delta[:n], dtype=float),
|
|
425
|
+
"grad_norm": np.asarray(result.history.grad_norm[:n], dtype=float),
|
|
426
|
+
}
|
|
427
|
+
else:
|
|
428
|
+
history = {}
|
|
429
|
+
|
|
430
|
+
average_iteration_ms = (
|
|
431
|
+
execution_seconds * 1000.0 / num_iters if num_iters else 0.0
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
if verbose >= 1:
|
|
435
|
+
if verbose >= 2:
|
|
436
|
+
print(_RULE)
|
|
437
|
+
print(f"status : {message}")
|
|
438
|
+
print(f"iters : {num_iters} (nfev={nfev}, njev={njev}, ls={n_linesearch_steps})")
|
|
439
|
+
print(f"value : {final_value:+.8e}")
|
|
440
|
+
print(f"grad_norm: {final_grad_norm:.3e}")
|
|
441
|
+
print(
|
|
442
|
+
f"compile: {compile_seconds:.2f} s execution: {execution_seconds:.2f} s"
|
|
443
|
+
f" avg/iter: {average_iteration_ms:.3f} ms"
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
return OptaxResult(
|
|
447
|
+
success=success,
|
|
448
|
+
status=status,
|
|
449
|
+
message=message,
|
|
450
|
+
num_iters=num_iters,
|
|
451
|
+
nfev=nfev,
|
|
452
|
+
njev=njev,
|
|
453
|
+
n_linesearch_steps=n_linesearch_steps,
|
|
454
|
+
final_value=final_value,
|
|
455
|
+
final_grad_norm=final_grad_norm,
|
|
456
|
+
x_element=x_element,
|
|
457
|
+
history=history,
|
|
458
|
+
compile_seconds=compile_seconds,
|
|
459
|
+
execution_seconds=execution_seconds,
|
|
460
|
+
average_iteration_ms=average_iteration_ms,
|
|
461
|
+
)
|