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.
@@ -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
+ )