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,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)
|