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,216 @@
1
+ """
2
+ SciPy optimizer adapters: ``scipy.optimize`` on flat real coordinates.
3
+
4
+ The gradient passed is ``X.riesz(F.grad(x))``. Structure is flattened away, so
5
+ ``bounds`` and ``constraints`` act on coordinates. SciPy's own models are
6
+ Euclidean, and complex domains are rejected.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ import numpy as np
13
+
14
+ from ._common import (
15
+ coordinate_gradient,
16
+ domain_with_geometry,
17
+ require_functional,
18
+ require_real_field,
19
+ )
20
+
21
+
22
+ def _flat_host(X: Any, element: Any) -> np.ndarray:
23
+ """Return ``element`` of ``X`` as a flat host ``float64`` array for SciPy."""
24
+ return np.asarray(X.flatten(element), dtype=np.float64).reshape(-1)
25
+
26
+
27
+ def _reject_args(kw: dict, fname: str) -> None:
28
+ """Reject SciPy's ``args`` parameter, which is meaningless for a Functional.
29
+
30
+ SciPy splats ``args`` into every ``fun(x, *args)``/``jac(x, *args)`` call, but
31
+ a SpaceCore ``Functional`` takes only its domain element. Rejecting here turns
32
+ a cryptic ``takes 1 positional argument but 2 were given`` into an actionable
33
+ error.
34
+ """
35
+ if "args" in kw:
36
+ raise ValueError(
37
+ f"{fname} does not support the SciPy 'args' parameter: a SpaceCore "
38
+ "Functional takes only its domain element. Bake any extra parameters "
39
+ "into F instead."
40
+ )
41
+
42
+
43
+ def _make_value_and_grad(F: Any, X: Any):
44
+ """Build SciPy ``fun``/``jac`` callables over flat host coordinate vectors."""
45
+ # SciPy works on flat real host vectors: unflatten into X, flatten the gradient back.
46
+ ctx = X.ctx
47
+
48
+ def fun(v: np.ndarray) -> float:
49
+ x = X.unflatten(ctx.asarray(np.asarray(v)))
50
+ return float(np.real(F.value(x)))
51
+
52
+ def jac(v: np.ndarray) -> np.ndarray:
53
+ x = X.unflatten(ctx.asarray(np.asarray(v)))
54
+ return _flat_host(X, coordinate_gradient(F, X, x))
55
+
56
+ return fun, jac
57
+
58
+
59
+ def minimize_scipy(
60
+ F: Any,
61
+ x0: Any,
62
+ *,
63
+ method: str = "L-BFGS-B",
64
+ jac: Any = True,
65
+ **kw: Any,
66
+ ) -> Any:
67
+ r"""
68
+ Minimize a functional with :func:`scipy.optimize.minimize`.
69
+
70
+ SciPy sees flat coordinates: the gradient handed over is ``X.riesz(F.grad(x))``,
71
+ which is the identity on a Euclidean space and required on a weighted one.
72
+
73
+ Parameters
74
+ ----------
75
+ F : Functional
76
+ Objective on a real inner-product space ``X = F.domain``.
77
+ x0 : array-like
78
+ Initial guess in ``F.domain``.
79
+ method : str, optional
80
+ SciPy method; ``"L-BFGS-B"`` by default.
81
+ jac : bool, str, callable, or None, optional
82
+ ``True`` supplies the coordinate gradient; ``False``/``None`` or a real
83
+ finite-difference string lets SciPy approximate it; a callable is forwarded.
84
+ ``"cs"`` is rejected.
85
+ **kw
86
+ Forwarded to SciPy; ``bounds`` and ``constraints`` act on flat coordinates.
87
+ ``args`` is not supported.
88
+
89
+ Returns
90
+ -------
91
+ scipy.optimize.OptimizeResult
92
+ SciPy's result, plus ``x_element``: the minimizer as an element of ``F.domain``.
93
+
94
+ Raises
95
+ ------
96
+ TypeError
97
+ If ``F`` is not a :class:`~spacecore.Functional` or its domain has no inner product.
98
+ ValueError
99
+ If the domain is complex, ``jac="cs"``, or ``args`` is passed.
100
+
101
+ See Also
102
+ --------
103
+ line_search_scipy : Wolfe line search with the same gradient handoff.
104
+
105
+ Examples
106
+ --------
107
+ Minimize ``f(x) = 1/2 x^T diag(3, 1) x - 3 x_0 - 2 x_1`` on a Euclidean
108
+ space; the minimizer is ``(1, 2)``.
109
+
110
+ >>> import numpy as np
111
+ >>> import spacecore as sc
112
+ >>> import spacecore_optimize as spo
113
+ >>> ctx = sc.Context(sc.NumpyOps(), dtype=np.float64)
114
+ >>> X = sc.DenseCoordinateSpace((2,), ctx)
115
+ >>> Q = sc.DenseLinOp(ctx.asarray([[3.0, 0.0], [0.0, 1.0]]), X, X, ctx)
116
+ >>> linear = sc.InnerProductFunctional(ctx.asarray([-3.0, -2.0]), X)
117
+ >>> F = sc.LinOpQuadraticForm(Q, linear)
118
+ >>> result = spo.minimize_scipy(F, X.zeros(), options={"gtol": 1e-10})
119
+ >>> bool(result.success)
120
+ True
121
+ >>> np.allclose(np.asarray(result.x_element), [1.0, 2.0])
122
+ True
123
+ """
124
+ import scipy.optimize as sopt
125
+
126
+ # A Functional on a real inner-product space, without SciPy's args.
127
+ F = require_functional(F, "minimize_scipy")
128
+ X = domain_with_geometry(F, "minimize_scipy")
129
+ require_real_field(X, "minimize_scipy")
130
+ _reject_args(kw, "minimize_scipy")
131
+
132
+ # jac=True: analytic coordinate gradient. Complex step is rejected. Else SciPy's own scheme.
133
+ fun, riesz_jac = _make_value_and_grad(F, X)
134
+ if jac is True:
135
+ jac_arg: Any = riesz_jac
136
+ elif jac is False or jac is None:
137
+ jac_arg = None
138
+ elif jac == "cs":
139
+ raise ValueError(
140
+ "minimize_scipy does not support jac='cs' (complex-step) on a real "
141
+ "domain: SciPy evaluates the objective at complex perturbations the "
142
+ "real backend rejects. Use the default jac=True (analytic "
143
+ "X.riesz(F.grad(x))) or a real finite-difference scheme "
144
+ "(jac='2-point' or '3-point')."
145
+ )
146
+ else:
147
+ jac_arg = jac
148
+
149
+ # Minimize on flat coordinates; also return the minimizer as an element of X.
150
+ x0_flat = _flat_host(X, x0)
151
+ result = sopt.minimize(fun, x0_flat, method=method, jac=jac_arg, **kw)
152
+ result.x_element = X.unflatten(X.ctx.asarray(np.asarray(result.x)))
153
+ return result
154
+
155
+
156
+ def line_search_scipy(F: Any, x: Any, d: Any, **kw: Any) -> Any:
157
+ r"""
158
+ Wolfe line search along ``d`` with :func:`scipy.optimize.line_search`.
159
+
160
+ The coordinate gradient ``X.riesz(F.grad(x))`` makes SciPy's slope equal the
161
+ directional derivative on any geometry.
162
+
163
+ Parameters
164
+ ----------
165
+ F : Functional
166
+ Objective on a real inner-product space ``X = F.domain``.
167
+ x : array-like
168
+ Current iterate in ``F.domain``.
169
+ d : array-like
170
+ Search direction; the new iterate is ``x + alpha * d``.
171
+ **kw
172
+ Forwarded to SciPy (``gfk``, ``c1``, ``c2``, ``amax``, ...). ``args`` is not supported.
173
+
174
+ Returns
175
+ -------
176
+ tuple
177
+ SciPy's ``(alpha, fc, gc, new_fval, old_fval, new_slope)``; ``alpha`` is ``None``
178
+ when the Wolfe conditions are not met.
179
+
180
+ Raises
181
+ ------
182
+ TypeError
183
+ If ``F`` is not a :class:`~spacecore.Functional` or its domain has no inner product.
184
+ ValueError
185
+ If the domain is complex or ``args`` is passed.
186
+
187
+ Examples
188
+ --------
189
+ A descent step on a convex quadratic returns a positive step length.
190
+
191
+ >>> import numpy as np
192
+ >>> import spacecore as sc
193
+ >>> import spacecore_optimize as spo
194
+ >>> ctx = sc.Context(sc.NumpyOps(), dtype=np.float64)
195
+ >>> X = sc.DenseCoordinateSpace((2,), ctx)
196
+ >>> Q = sc.DenseLinOp(ctx.asarray([[3.0, 0.0], [0.0, 1.0]]), X, X, ctx)
197
+ >>> F = sc.LinOpQuadraticForm(Q)
198
+ >>> x = ctx.asarray([1.0, 1.0])
199
+ >>> d = X.scale(-1.0, F.grad(x))
200
+ >>> alpha = spo.line_search_scipy(F, x, d)[0]
201
+ >>> bool(alpha > 0.0)
202
+ True
203
+ """
204
+ import scipy.optimize as sopt
205
+
206
+ # A Functional on a real inner-product space, without SciPy's args.
207
+ F = require_functional(F, "line_search_scipy")
208
+ X = domain_with_geometry(F, "line_search_scipy")
209
+ require_real_field(X, "line_search_scipy")
210
+ _reject_args(kw, "line_search_scipy")
211
+
212
+ # SciPy's line search on the flat coordinates of x and the direction d.
213
+ fun, jac = _make_value_and_grad(F, X)
214
+ xk = _flat_host(X, x)
215
+ pk = _flat_host(X, d)
216
+ return sopt.line_search(fun, jac, xk, pk, **kw)
@@ -0,0 +1,36 @@
1
+ """Named functionals and closed-form proximal steps built on SpaceCore.
2
+
3
+ Least squares, entropy objectives, the Huber loss, spectral lifts, the
4
+ realified (stacked real) view and the metric-aware shrinkage primitive. The
5
+ norms the SpaceCore prox rules use (``LpNormFunctional``,
6
+ ``SquaredL2NormFunctional``, ``L1NormFunctional``) stay in SpaceCore.
7
+ """
8
+
9
+ from ._entropy import KLDivergenceFunctional, NegativeEntropyFunctional
10
+ from ._huber import HuberFunctional
11
+ from ._least_squares import least_squares
12
+ from ._proximal import generalized_shrinkage, project_nonneg, prox_l1, prox_l2sq
13
+ from ._realified import RealifiedFunctional, realify
14
+ from ._spectral import (
15
+ NuclearNormFunctional,
16
+ SpectralFunctional,
17
+ eigenvalue_space,
18
+ spectralize,
19
+ )
20
+
21
+ __all__ = [
22
+ "HuberFunctional",
23
+ "KLDivergenceFunctional",
24
+ "NegativeEntropyFunctional",
25
+ "NuclearNormFunctional",
26
+ "RealifiedFunctional",
27
+ "SpectralFunctional",
28
+ "eigenvalue_space",
29
+ "generalized_shrinkage",
30
+ "least_squares",
31
+ "project_nonneg",
32
+ "prox_l1",
33
+ "prox_l2sq",
34
+ "realify",
35
+ "spectralize",
36
+ ]
@@ -0,0 +1,134 @@
1
+ """
2
+ Entropy objectives: negative entropy and KL divergence.
3
+
4
+ The negative entropy is the standard mirror-descent kernel (Beck, *First-Order
5
+ Methods in Optimization*, Example 9.10, on the probability simplex); its spectral
6
+ lift is ``Tr(X log X)``, the negative von Neumann entropy for a density matrix.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from typing import Any, cast
11
+
12
+ from spacecore import CheckLevel, Context, checked_method
13
+ from spacecore.functional._base import Domain
14
+ from spacecore.functional._linear import _convert_space_element
15
+ from spacecore.functional.tools._coordinate import _CoordinateFunctional
16
+
17
+
18
+ class NegativeEntropyFunctional(_CoordinateFunctional[Domain]):
19
+ r"""
20
+ Negative entropy ``sum_i x_i log x_i``, with ``0 log 0 = 0``; the gradient ``log x_i + 1`` needs ``x_i > 0``.
21
+
22
+ Parameters
23
+ ----------
24
+ dom : Space
25
+ Domain.
26
+ ctx : Context, str, or None, optional
27
+ Backend context; resolved from ``dom`` when omitted.
28
+ check_level : {"none", "cheap", "standard", "strict"}, optional
29
+ Validation level; the ambient default when omitted.
30
+
31
+ Examples
32
+ --------
33
+ >>> import numpy as np
34
+ >>> import spacecore as sc
35
+ >>> import spacecore_optimize as spo
36
+ >>> ctx = sc.Context(sc.NumpyOps(), dtype=np.float64)
37
+ >>> X = sc.DenseCoordinateSpace((2,), ctx)
38
+ >>> f = spo.NegativeEntropyFunctional(X)
39
+ >>> float(f.value(ctx.asarray([1.0, 1.0])))
40
+ 0.0
41
+ >>> np.asarray(f.grad(ctx.asarray([1.0, 1.0]))) # log(x) + 1
42
+ array([1., 1.])
43
+ """
44
+
45
+ _fields = ("dom", "ctx")
46
+
47
+ def __init__(
48
+ self,
49
+ dom: Domain,
50
+ ctx: Context | str | None = None,
51
+ check_level: CheckLevel | None = None,
52
+ ) -> None:
53
+ super().__init__(dom, ctx, check_level=check_level)
54
+
55
+ @checked_method(in_space="domain", out_space="codomain")
56
+ def value(self, x: Any) -> Any:
57
+ """Return ``sum_i x_i log x_i`` with ``0 log 0 = 0``."""
58
+ # sum x log x, with 0 log 0 = 0 (log evaluated on a safe input).
59
+ o = self.ops
60
+ positive = x > 0
61
+ safe = o.where(positive, x, cast(Any, 1.0))
62
+ return o.sum(o.where(positive, x * o.log(safe), cast(Any, 0.0)))
63
+
64
+ def _coordinate_grad(self, x: Any) -> Any:
65
+ """Euclidean coordinate gradient ``log x_i + 1`` (defined for ``x_i > 0``)."""
66
+ return self.ops.log(x) + 1.0
67
+
68
+ class KLDivergenceFunctional(_CoordinateFunctional[Domain]):
69
+ r"""
70
+ Kullback-Leibler divergence ``sum_i x_i log(x_i / t_i)`` to a positive target ``t``.
71
+
72
+ Defined for ``x_i >= 0`` (``0 log 0 = 0``); the gradient is ``log(x_i / t_i) + 1``.
73
+ With ``t`` all ones it is :class:`NegativeEntropyFunctional`. On probability
74
+ vectors this is KL divergence (Beck, *First-Order Methods in Optimization*,
75
+ Example 9.10, Eq. (9.13)); without equal total mass it can be negative.
76
+
77
+ Parameters
78
+ ----------
79
+ target : array-like
80
+ Target ``t`` in ``dom``, strictly positive.
81
+ dom : Space
82
+ Domain.
83
+ ctx : Context, str, or None, optional
84
+ Backend context; resolved from ``dom`` when omitted.
85
+ check_level : {"none", "cheap", "standard", "strict"}, optional
86
+ Validation level; the ambient default when omitted.
87
+
88
+ Examples
89
+ --------
90
+ >>> import numpy as np
91
+ >>> import spacecore as sc
92
+ >>> import spacecore_optimize as spo
93
+ >>> ctx = sc.Context(sc.NumpyOps(), dtype=np.float64)
94
+ >>> X = sc.DenseCoordinateSpace((2,), ctx)
95
+ >>> f = spo.KLDivergenceFunctional(ctx.asarray([1.0, 1.0]), X)
96
+ >>> float(f.value(ctx.asarray([1.0, 1.0]))) # zero divergence at x == target
97
+ 0.0
98
+ >>> np.asarray(f.grad(ctx.asarray([1.0, 1.0]))) # log(x / t) + 1
99
+ array([1., 1.])
100
+ """
101
+
102
+ _fields = ("target:_target", "dom", "ctx")
103
+ _dynamic = ("target",)
104
+
105
+ def __init__(
106
+ self,
107
+ target: Any,
108
+ dom: Domain,
109
+ ctx: Context | str | None = None,
110
+ check_level: CheckLevel | None = None,
111
+ ) -> None:
112
+ super().__init__(dom, ctx, check_level=check_level)
113
+ # Store the target on the functional's backend and check it belongs.
114
+ self._target = _convert_space_element(self.domain, target)
115
+ if self._checks_at_least("standard"):
116
+ self.domain._check_member(self._target)
117
+
118
+ @property
119
+ def target(self) -> Any:
120
+ """Stored reference element ``t``."""
121
+ return self._target
122
+
123
+ @checked_method(in_space="domain", out_space="codomain")
124
+ def value(self, x: Any) -> Any:
125
+ """Return ``sum_i x_i log(x_i / t_i)`` with ``0 log 0 = 0``."""
126
+ # sum x log(x / t), with 0 log 0 = 0.
127
+ o = self.ops
128
+ positive = x > 0
129
+ ratio = o.where(positive, x / self._target, cast(Any, 1.0))
130
+ return o.sum(o.where(positive, x * o.log(ratio), cast(Any, 0.0)))
131
+
132
+ def _coordinate_grad(self, x: Any) -> Any:
133
+ """Euclidean coordinate gradient ``log(x_i / t_i) + 1`` (for ``x_i > 0``)."""
134
+ return self.ops.log(x / self._target) + 1.0
@@ -0,0 +1,77 @@
1
+ """The separable Huber loss functional."""
2
+ from __future__ import annotations
3
+
4
+ import math
5
+ from typing import Any
6
+
7
+ from spacecore import CheckLevel, Context, checked_method
8
+ from spacecore.functional._base import Domain
9
+ from spacecore.functional.tools._coordinate import _CoordinateFunctional
10
+
11
+
12
+ class HuberFunctional(_CoordinateFunctional[Domain]):
13
+ r"""
14
+ Separable Huber loss ``sum_i h_delta(x_i)``.
15
+
16
+ ``h_delta(r)`` is ``r^2 / 2`` for ``|r| <= delta`` and ``delta (|r| - delta / 2)``
17
+ beyond; it is ``C^1``. This is ``delta * H_delta`` in Beck, *First-Order Methods
18
+ in Optimization*, Examples 6.54 and 6.62; multiplying both branches by ``delta``
19
+ gives this scaling and the derivative ``r`` or ``delta sign(r)``.
20
+
21
+ Parameters
22
+ ----------
23
+ dom : Space
24
+ Domain.
25
+ delta : float
26
+ Positive, finite threshold.
27
+ ctx : Context, str, or None, optional
28
+ Backend context; resolved from ``dom`` when omitted.
29
+ check_level : {"none", "cheap", "standard", "strict"}, optional
30
+ Validation level; the ambient default when omitted.
31
+
32
+ Examples
33
+ --------
34
+ >>> import numpy as np
35
+ >>> import spacecore as sc
36
+ >>> import spacecore_optimize as spo
37
+ >>> ctx = sc.Context(sc.NumpyOps(), dtype=np.float64)
38
+ >>> X = sc.DenseCoordinateSpace((2,), ctx)
39
+ >>> f = spo.HuberFunctional(X, 1.0)
40
+ >>> float(f.value(ctx.asarray([0.5, 3.0]))) # 0.125 + (3 - 0.5)
41
+ 2.625
42
+ """
43
+
44
+ _fields = ("dom", "delta", "ctx")
45
+
46
+ def __init__(
47
+ self,
48
+ dom: Domain,
49
+ delta: Any,
50
+ ctx: Context | str | None = None,
51
+ check_level: CheckLevel | None = None,
52
+ ) -> None:
53
+ super().__init__(dom, ctx, check_level=check_level)
54
+ # delta must be finite and positive.
55
+ delta = float(delta)
56
+ if not math.isfinite(delta) or delta <= 0.0:
57
+ raise ValueError(f"HuberFunctional requires a finite delta > 0, got {delta}.")
58
+ self.delta = delta
59
+
60
+ @checked_method(in_space="domain", out_space="codomain")
61
+ def value(self, x: Any) -> Any:
62
+ """Return ``sum_i h_delta(x_i)``."""
63
+ # Quadratic inside |x| <= delta, linear outside.
64
+ o = self.ops
65
+ d = self.delta
66
+ a = o.abs(x)
67
+ quadratic = 0.5 * a * a
68
+ linear = d * (a - 0.5 * d)
69
+ return o.sum(o.where(a <= d, quadratic, linear))
70
+
71
+ def _coordinate_grad(self, x: Any) -> Any:
72
+ """Euclidean coordinate gradient: ``x`` (quadratic) or ``delta sign(x)`` (tail)."""
73
+ # Gradient: x inside, delta sign(x) outside.
74
+ o = self.ops
75
+ d = self.delta
76
+ a = o.abs(x)
77
+ return o.where(a <= d, x, d * o.sign(x))
@@ -0,0 +1,106 @@
1
+ """The :func:`least_squares` constructor.
2
+
3
+ The ``0.4.0`` ergonomics study found every least-squares persona hand-expanding
4
+ ``1/2 ||A x - b||^2`` into ``Q = A.H @ A`` and ``c = -A^# b`` to fit a
5
+ :class:`~spacecore.functional.LinOpQuadraticForm`. This module provides that
6
+ expansion as a single front door. It returns an existing
7
+ ``LinOpQuadraticForm`` -- no new type -- so the whole quadratic toolbox
8
+ (gradient, Hessian action, batched evaluation, composition) applies unchanged.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import numpy as _np
13
+ from typing import Any
14
+
15
+ from spacecore import DiagonalLinOp, InnerProductFunctional, LinOp, LinOpQuadraticForm
16
+ from spacecore.functional._linear import _convert_space_element
17
+
18
+
19
+ def least_squares(
20
+ A: LinOp,
21
+ b: Any,
22
+ *,
23
+ weights: Any = None,
24
+ scale: float = 0.5,
25
+ ) -> LinOpQuadraticForm:
26
+ r"""
27
+ Build ``scale * ||A x - b||^2`` as a :class:`~spacecore.functional.LinOpQuadraticForm`.
28
+
29
+ It is ``1/2 <x, Q x> + <c, x> + a`` with ``Q = 2 scale A.H A``, ``c = -2 scale A.H b``
30
+ and ``a = scale <b, b>``, all in the operator's own geometry. Expanding
31
+ the squared norm and moving ``A`` by the adjoint identity gives this formula
32
+ on real spaces; on complex spaces the cross term must be ``Re<c, x>``.
33
+ The current linear term retains the full pairing, so the squared-norm
34
+ interpretation requires real data. Trefethen & Bau, *Numerical Linear Algebra*,
35
+ Lecture 2, pp. 11-12.
36
+
37
+ Parameters
38
+ ----------
39
+ A : LinOp
40
+ Forward operator ``X -> Y``.
41
+ b : array-like
42
+ Observation in ``A.codomain``.
43
+ weights : array-like or None, optional
44
+ Positive diagonal residual weights ``w``, giving ``scale <r, diag(w) r>``.
45
+ scale : float, optional
46
+ Positive factor; ``0.5`` gives the textbook ``1/2 ||A x - b||^2``.
47
+
48
+ Returns
49
+ -------
50
+ LinOpQuadraticForm
51
+ The objective; its gradient is ``2 scale A.H (W (A x - b))``.
52
+
53
+ Examples
54
+ --------
55
+ >>> import numpy as np
56
+ >>> import spacecore as sc
57
+ >>> import spacecore_optimize as spo
58
+ >>> ctx = sc.Context(sc.NumpyOps(), dtype=np.float64)
59
+ >>> X = sc.DenseCoordinateSpace((2,), ctx)
60
+ >>> Y = sc.DenseCoordinateSpace((2,), ctx)
61
+ >>> A = sc.DenseLinOp(ctx.asarray([[1.0, 0.0], [0.0, 2.0]]), X, Y, ctx)
62
+ >>> f = spo.least_squares(A, ctx.asarray([1.0, 4.0]))
63
+ >>> float(f.value(ctx.asarray([1.0, 2.0])))
64
+ 0.0
65
+ """
66
+ # Validate A and scale.
67
+ if not isinstance(A, LinOp):
68
+ raise TypeError(f"least_squares requires A to be a LinOp, got {type(A).__name__}.")
69
+
70
+ scale = float(scale)
71
+ if not _np.isfinite(scale):
72
+ raise ValueError(f"least_squares requires a finite scale, got {scale}.")
73
+
74
+ X = A.domain
75
+ Y = A.codomain
76
+ ctx = A.ctx
77
+ b = _convert_space_element(Y, b) # native members pass through untouched
78
+ two_scale = 2.0 * scale
79
+
80
+ # Unweighted: Gram A* A, right-hand side A* b, energy <b, b>.
81
+ if weights is None:
82
+ gram = A.H @ A
83
+ rhs = A.H.apply(b)
84
+ b_energy = Y.inner(b, b)
85
+ # Weighted: the same with W = diag(w) inserted, after validating w.
86
+ else:
87
+ w = ctx.asarray(weights)
88
+ w_np = _np.asarray(w)
89
+ if tuple(w_np.shape) != tuple(Y.shape):
90
+ raise ValueError(
91
+ f"least_squares weights must have the codomain shape {tuple(Y.shape)}, "
92
+ f"got {tuple(w_np.shape)}."
93
+ )
94
+ if not _np.all(_np.isfinite(w_np)) or not _np.all(w_np > 0):
95
+ raise ValueError("least_squares weights must be strictly positive and finite.")
96
+ W = DiagonalLinOp(w, Y, ctx)
97
+ Wb = W.apply(b)
98
+ gram = A.H @ W @ A
99
+ rhs = A.H.apply(Wb)
100
+ b_energy = Y.inner(b, Wb)
101
+
102
+ # Assemble 1/2 <x, Q x> + <c, x> + a with Q = 2s Gram, c = -2s rhs, a = s energy.
103
+ Q = gram if two_scale == 1.0 else two_scale * gram
104
+ linear = InnerProductFunctional(X.scale(-two_scale, rhs), X, ctx)
105
+ offset = scale * ctx.ops.real(b_energy)
106
+ return LinOpQuadraticForm(Q, linear, offset, ctx)