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,193 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Closed-form proximal steps in the space metric.
|
|
3
|
+
|
|
4
|
+
For diagonal weights, divide each scalar proximal objective by ``w_i`` to obtain
|
|
5
|
+
the Euclidean problem with step ``t / w_i``; other metrics raise.
|
|
6
|
+
Beck, *First-Order Methods in Optimization*, Thm 6.6 and Lemma 6.5.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from spacecore import WeightedInnerProduct
|
|
13
|
+
from spacecore.functional._prox import _soft_threshold, validate_step
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _diagonal_weights_or_raise(X: Any, fname: str) -> Any:
|
|
17
|
+
"""Return diagonal weights (``None`` for Euclidean) or raise on a non-diagonal metric."""
|
|
18
|
+
# Euclidean gives None, diagonal gives its weights, anything else cannot separate.
|
|
19
|
+
geometry = getattr(X, "geometry", None)
|
|
20
|
+
if geometry is None:
|
|
21
|
+
raise TypeError(
|
|
22
|
+
f"{fname} requires an inner-product space with a geometry; "
|
|
23
|
+
f"got {type(X).__name__}."
|
|
24
|
+
)
|
|
25
|
+
if geometry.is_euclidean:
|
|
26
|
+
return None
|
|
27
|
+
if isinstance(geometry, WeightedInnerProduct):
|
|
28
|
+
return X.ops.real(geometry.weights)
|
|
29
|
+
raise ValueError(
|
|
30
|
+
f"{fname} is a separable closed form valid only for Euclidean or diagonal "
|
|
31
|
+
f"(weighted) metrics; the metric {type(geometry).__name__} is not diagonal, "
|
|
32
|
+
f"so the subproblem does not separate. Refusing to return a wrong "
|
|
33
|
+
f"(separable) answer."
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _require_member_shape(X: Any, arr: Any, name: str, fname: str) -> None:
|
|
38
|
+
"""Raise when ``arr`` does not have the coordinate shape of ``X``."""
|
|
39
|
+
shape = tuple(getattr(arr, "shape", ()))
|
|
40
|
+
if shape != tuple(X.shape):
|
|
41
|
+
raise ValueError(
|
|
42
|
+
f"{fname} {name} must have the space shape {tuple(X.shape)}, got {shape}."
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def generalized_shrinkage(
|
|
47
|
+
X: Any,
|
|
48
|
+
*,
|
|
49
|
+
c: Any,
|
|
50
|
+
x0: Any,
|
|
51
|
+
eps: float,
|
|
52
|
+
lam: float = 0.0,
|
|
53
|
+
nonneg: bool = False,
|
|
54
|
+
) -> Any:
|
|
55
|
+
r"""
|
|
56
|
+
Solve ``min_x Re<c, x>_X + eps ||x - x0||_X^2 + lam ||x||_1`` coordinatewise, optionally with ``x >= 0``.
|
|
57
|
+
|
|
58
|
+
The smooth minimizer is ``v = x0 - c / (2 eps)``; the l1 term soft-thresholds it
|
|
59
|
+
with width ``lam / (2 eps w_i)`` for diagonal metric weights ``w``: complete
|
|
60
|
+
the square, then divide by ``2 eps w_i``. Beck, *First-Order Methods in
|
|
61
|
+
Optimization*, Sec. 6.2.2 and Lemma 6.5; for complex coordinates, minimizing
|
|
62
|
+
over phase aligns ``x_i`` with ``v_i`` and leaves the same scalar radius problem.
|
|
63
|
+
|
|
64
|
+
Parameters
|
|
65
|
+
----------
|
|
66
|
+
X : Space
|
|
67
|
+
Euclidean or diagonal-metric space; other metrics raise.
|
|
68
|
+
c : array-like
|
|
69
|
+
Metric gradient in ``X``.
|
|
70
|
+
x0 : array-like
|
|
71
|
+
Proximal center in ``X``.
|
|
72
|
+
eps : float
|
|
73
|
+
Positive quadratic weight.
|
|
74
|
+
lam : float, optional
|
|
75
|
+
Non-negative l1 weight; ``0`` disables thresholding.
|
|
76
|
+
nonneg : bool, optional
|
|
77
|
+
Constrain to ``x >= 0`` (real spaces only).
|
|
78
|
+
|
|
79
|
+
Returns
|
|
80
|
+
-------
|
|
81
|
+
Element
|
|
82
|
+
The minimizer.
|
|
83
|
+
"""
|
|
84
|
+
# Validate eps, lam, the metric, the shapes, and nonneg on real spaces only.
|
|
85
|
+
validate_step(eps, X, "generalized_shrinkage eps")
|
|
86
|
+
validate_step(lam, X, "generalized_shrinkage lam", positive=False)
|
|
87
|
+
|
|
88
|
+
weights = _diagonal_weights_or_raise(X, "generalized_shrinkage")
|
|
89
|
+
|
|
90
|
+
ops = X.ops
|
|
91
|
+
c = X.ctx.asarray(c)
|
|
92
|
+
x0 = X.ctx.asarray(x0)
|
|
93
|
+
_require_member_shape(X, c, "c", "generalized_shrinkage")
|
|
94
|
+
_require_member_shape(X, x0, "x0", "generalized_shrinkage")
|
|
95
|
+
if nonneg and ops.is_complex_dtype(ops.get_dtype(x0)):
|
|
96
|
+
raise ValueError("generalized_shrinkage(nonneg=True) is defined for real spaces only.")
|
|
97
|
+
|
|
98
|
+
# Data step: minimizer of <c, x>_X + eps ||x - x0||^2_X. The metric weight
|
|
99
|
+
# cancels here precisely because c is a metric gradient.
|
|
100
|
+
v = x0 - c / (2.0 * eps)
|
|
101
|
+
|
|
102
|
+
# Threshold width lam / (2 eps w_i), with w_i = 1 on Euclidean spaces.
|
|
103
|
+
if weights is None:
|
|
104
|
+
tau = lam / (2.0 * eps)
|
|
105
|
+
else:
|
|
106
|
+
tau = lam / (2.0 * eps * weights)
|
|
107
|
+
|
|
108
|
+
# On x >= 0 the l1 term is linear, so the step is a one-sided shift.
|
|
109
|
+
if nonneg:
|
|
110
|
+
return ops.maximum(v - tau, ops.zeros_like(v))
|
|
111
|
+
# Soft-threshold; tau == 0 collapses to the identity v.
|
|
112
|
+
return _soft_threshold(ops, v, tau)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def prox_l1(v: Any, t: Any, X: Any, *, nonneg: bool = False) -> Any:
|
|
116
|
+
r"""
|
|
117
|
+
Prox of ``t ||.||_1`` in the space metric: soft-threshold with width ``t / w_i``.
|
|
118
|
+
|
|
119
|
+
Parameters
|
|
120
|
+
----------
|
|
121
|
+
v : array-like
|
|
122
|
+
Point in ``X``.
|
|
123
|
+
t : float
|
|
124
|
+
Non-negative threshold.
|
|
125
|
+
X : Space
|
|
126
|
+
Euclidean or diagonal-metric space.
|
|
127
|
+
nonneg : bool, optional
|
|
128
|
+
Constrain to ``x >= 0``, giving ``max(v_i - t / w_i, 0)``; at ``t = 0`` this is
|
|
129
|
+
:func:`project_nonneg` (Beck, *First-Order Methods in Optimization*, Lemma 6.5).
|
|
130
|
+
|
|
131
|
+
Returns
|
|
132
|
+
-------
|
|
133
|
+
Element
|
|
134
|
+
The thresholded point; divide each scalar objective by ``w_i`` to apply
|
|
135
|
+
Beck, *First-Order Methods in Optimization*, Example 6.8.
|
|
136
|
+
"""
|
|
137
|
+
# prox of t ||.||_1 = shrinkage with c = 0, x0 = v, eps = 1/2.
|
|
138
|
+
validate_step(t, X, "prox_l1", positive=False)
|
|
139
|
+
v = X.ctx.asarray(v)
|
|
140
|
+
_require_member_shape(X, v, "v", "prox_l1")
|
|
141
|
+
return generalized_shrinkage(X, c=X.zeros(), x0=v, eps=0.5, lam=t, nonneg=nonneg)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def prox_l2sq(v: Any, t: Any, X: Any, *, nonneg: bool = False) -> Any:
|
|
145
|
+
r"""
|
|
146
|
+
Prox of ``(t / 2) ||.||_X^2``: the shrinkage ``v / (1 + t)``.
|
|
147
|
+
|
|
148
|
+
Parameters
|
|
149
|
+
----------
|
|
150
|
+
v : array-like
|
|
151
|
+
Point in ``X``.
|
|
152
|
+
t : float
|
|
153
|
+
Non-negative step size.
|
|
154
|
+
X : Space
|
|
155
|
+
Euclidean or diagonal-metric space.
|
|
156
|
+
nonneg : bool, optional
|
|
157
|
+
Constrain to ``x >= 0``, giving ``max(v, 0) / (1 + t)``.
|
|
158
|
+
|
|
159
|
+
Returns
|
|
160
|
+
-------
|
|
161
|
+
Element
|
|
162
|
+
The shrunk point; cancel ``w_i`` in the scalar quadratic optimality equation.
|
|
163
|
+
Beck, *First-Order Methods in Optimization*, Sec. 6.2.3.
|
|
164
|
+
"""
|
|
165
|
+
# prox of (t/2) ||.||^2 = shrinkage with c = -v, x0 = 0, eps = (1 + t) / 2.
|
|
166
|
+
validate_step(t, X, "prox_l2sq", positive=False)
|
|
167
|
+
v = X.ctx.asarray(v)
|
|
168
|
+
_require_member_shape(X, v, "v", "prox_l2sq")
|
|
169
|
+
return generalized_shrinkage(
|
|
170
|
+
X, c=-v, x0=X.zeros(), eps=0.5 * (1.0 + t), lam=0.0, nonneg=nonneg
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def project_nonneg(v: Any, X: Any) -> Any:
|
|
175
|
+
r"""
|
|
176
|
+
Project onto ``x >= 0`` in the space metric: coordinatewise ``max(v, 0)``.
|
|
177
|
+
|
|
178
|
+
Parameters
|
|
179
|
+
----------
|
|
180
|
+
v : array-like
|
|
181
|
+
Point in ``X``.
|
|
182
|
+
X : Space
|
|
183
|
+
Euclidean or diagonal-metric space.
|
|
184
|
+
|
|
185
|
+
Returns
|
|
186
|
+
-------
|
|
187
|
+
Element
|
|
188
|
+
``max(v, 0)``; positive diagonal weights do not change each scalar minimizer.
|
|
189
|
+
Beck, *First-Order Methods in Optimization*, Lemma 6.5 (``mu = 0``).
|
|
190
|
+
"""
|
|
191
|
+
v = X.ctx.asarray(v)
|
|
192
|
+
_require_member_shape(X, v, "v", "project_nonneg")
|
|
193
|
+
return generalized_shrinkage(X, c=X.zeros(), x0=v, eps=0.5, lam=0.0, nonneg=True)
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
r"""
|
|
2
|
+
Real-coordinate view of a complex-domain functional.
|
|
3
|
+
|
|
4
|
+
For real-valued ``F`` with metric gradient ``g`` and ``v = a + i b``, the real
|
|
5
|
+
gradient is ``(Re g_v, Im g_v)`` where ``g_v = flatten(X.riesz(g))``. The view
|
|
6
|
+
writes that bookkeeping once: ``DF[h] = Re(g_v^H flatten(h))`` gives the stacked
|
|
7
|
+
partials. Kreutz-Delgado, *The Complex Gradient Operator and the CR-Calculus*,
|
|
8
|
+
arXiv:0906.4835, Eqs. (51), (55)-(56); SpaceCore's ``g`` is twice that paper's
|
|
9
|
+
complex gradient. Stacking is an isometry to this Euclidean view when ``X`` is
|
|
10
|
+
Euclidean; a non-Euclidean ``X`` requires the displayed Riesz conversion.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import Any, Tuple
|
|
15
|
+
|
|
16
|
+
from spacecore import (
|
|
17
|
+
Context,
|
|
18
|
+
CoordinateSpace,
|
|
19
|
+
DenseCoordinateSpace,
|
|
20
|
+
Functional,
|
|
21
|
+
checked_method,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class RealifiedFunctional(Functional):
|
|
26
|
+
r"""
|
|
27
|
+
View of a complex-domain functional over stacked real coordinates ``(Re v, Im v)``.
|
|
28
|
+
|
|
29
|
+
The real gradient is :math:`(\operatorname{Re} g_v, \operatorname{Im} g_v)` with
|
|
30
|
+
:math:`g_v = \operatorname{flatten}(X.\operatorname{riesz}(g))`.
|
|
31
|
+
|
|
32
|
+
Parameters
|
|
33
|
+
----------
|
|
34
|
+
base : Functional
|
|
35
|
+
Functional on a complex :class:`~spacecore.space.CoordinateSpace`.
|
|
36
|
+
|
|
37
|
+
Raises
|
|
38
|
+
------
|
|
39
|
+
TypeError
|
|
40
|
+
If the base domain is not a coordinate space.
|
|
41
|
+
ValueError
|
|
42
|
+
If the base domain is already real.
|
|
43
|
+
|
|
44
|
+
Notes
|
|
45
|
+
-----
|
|
46
|
+
:class:`~spacecore.HermitianSpace` stores complex entries but is a real space of
|
|
47
|
+
dimension :math:`n^2` (``n`` real diagonal entries and two real coordinates per
|
|
48
|
+
off-diagonal pair). Its stacked representation still has Hermitian constraints;
|
|
49
|
+
this view does not supply an unconstrained extension of the functional.
|
|
50
|
+
|
|
51
|
+
Examples
|
|
52
|
+
--------
|
|
53
|
+
>>> import numpy as np
|
|
54
|
+
>>> import spacecore as sc
|
|
55
|
+
>>> import spacecore_optimize as spo
|
|
56
|
+
>>> ctx = sc.Context(sc.NumpyOps(), dtype=np.complex128)
|
|
57
|
+
>>> X = sc.DenseCoordinateSpace((2,), ctx=ctx)
|
|
58
|
+
>>> F = sc.SquaredL2NormFunctional(X)
|
|
59
|
+
>>> R = spo.realify(F)
|
|
60
|
+
>>> R.domain.shape
|
|
61
|
+
(4,)
|
|
62
|
+
>>> float(R.value(R.to_real(ctx.asarray([3.0 + 4.0j, 0.0]))))
|
|
63
|
+
12.5
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
_fields = ("base",)
|
|
67
|
+
_dynamic = ("base",)
|
|
68
|
+
|
|
69
|
+
def __init__(self, base: Functional) -> None:
|
|
70
|
+
# Only complex coordinate spaces can be split into real and imaginary parts.
|
|
71
|
+
X = base.domain
|
|
72
|
+
if not isinstance(X, CoordinateSpace):
|
|
73
|
+
raise TypeError(
|
|
74
|
+
"RealifiedFunctional requires a CoordinateSpace domain; "
|
|
75
|
+
f"got {type(X).__name__}."
|
|
76
|
+
)
|
|
77
|
+
if X.field == "real":
|
|
78
|
+
raise ValueError(
|
|
79
|
+
"The base functional already has a real domain; use it directly."
|
|
80
|
+
)
|
|
81
|
+
ops = X.ops
|
|
82
|
+
# check_level is a property of the bound object, not of the Context, so
|
|
83
|
+
# it is threaded through the constructors rather than the context.
|
|
84
|
+
# The view's domain: real coordinates of twice the size.
|
|
85
|
+
real_ctx = Context(ops, dtype=ops.real_dtype(X.dtype))
|
|
86
|
+
dom = DenseCoordinateSpace(
|
|
87
|
+
(2 * X.size,), real_ctx, check_level=X.check_level
|
|
88
|
+
)
|
|
89
|
+
super().__init__(dom, real_ctx, check_level=X.check_level)
|
|
90
|
+
self.base = base
|
|
91
|
+
self.complex_space = X
|
|
92
|
+
|
|
93
|
+
def to_real(self, y: Any) -> Any:
|
|
94
|
+
"""Flatten a base-domain element into stacked real coordinates."""
|
|
95
|
+
ops = self.ops
|
|
96
|
+
v = self.complex_space.flatten(y)
|
|
97
|
+
return ops.concatenate([ops.real(v), ops.imag(v)])
|
|
98
|
+
|
|
99
|
+
def from_real(self, w: Any) -> Any:
|
|
100
|
+
"""Rebuild a base-domain element from stacked real coordinates."""
|
|
101
|
+
m = self.complex_space.size
|
|
102
|
+
v = w[:m] + 1j * w[m:]
|
|
103
|
+
return self.complex_space.unflatten(self.complex_space.ctx.asarray(v))
|
|
104
|
+
|
|
105
|
+
# Output-only: the input is validated by ``base.value`` against the *complex*
|
|
106
|
+
# domain after ``from_real``, so an ``in_space`` here would check ``w``
|
|
107
|
+
# against the wrong space.
|
|
108
|
+
@checked_method(out_space="codomain")
|
|
109
|
+
def value(self, w: Any, *args: Any, **kwargs: Any) -> Any:
|
|
110
|
+
"""Return ``base.value`` at the complex element ``w`` encodes."""
|
|
111
|
+
return self.base.value(self.from_real(w), *args, **kwargs)
|
|
112
|
+
|
|
113
|
+
def grad(self, w: Any, *args: Any, **kwargs: Any) -> Any:
|
|
114
|
+
"""Return the stacked real gradient ``(Re g_v, Im g_v)``."""
|
|
115
|
+
return self.value_and_grad(w, *args, **kwargs)[1]
|
|
116
|
+
|
|
117
|
+
def value_and_grad(self, w: Any, *args: Any, **kwargs: Any) -> Tuple[Any, Any]:
|
|
118
|
+
"""Return ``(value, stacked real gradient)`` from one fused base evaluation.
|
|
119
|
+
|
|
120
|
+
``riesz`` maps the base's metric gradient back to coordinates before the
|
|
121
|
+
real/imaginary split; skipping it would silently return the wrong vector
|
|
122
|
+
on any non-Euclidean geometry.
|
|
123
|
+
"""
|
|
124
|
+
X, ops = self.complex_space, self.ops
|
|
125
|
+
# Evaluate the base at v = a + i b, then stack Re and Im of its coordinate gradient.
|
|
126
|
+
val, g = self.base.value_and_grad(self.from_real(w), *args, **kwargs)
|
|
127
|
+
gv = X.flatten(X.riesz(g))
|
|
128
|
+
return val, ops.concatenate([ops.real(gv), ops.imag(gv)])
|
|
129
|
+
|
|
130
|
+
def _convert(self, new_ctx: Context) -> "RealifiedFunctional":
|
|
131
|
+
"""Convert the wrapped functional to ``new_ctx`` and re-derive the view.
|
|
132
|
+
|
|
133
|
+
``new_ctx`` describes the *real view*, so the base must be converted to
|
|
134
|
+
the matching **complex** dtype — passing the real context straight
|
|
135
|
+
through would make the base's domain real and the view impossible to
|
|
136
|
+
rebuild. The pairing comes from ``ops.complex_dtype`` because it is not
|
|
137
|
+
portable to derive: NumPy promotes ``float32`` against a Python complex
|
|
138
|
+
to ``complex128``, JAX and Torch to ``complex64``.
|
|
139
|
+
"""
|
|
140
|
+
ops = new_ctx.ops
|
|
141
|
+
base_ctx = Context(ops, dtype=ops.complex_dtype(new_ctx.dtype))
|
|
142
|
+
return RealifiedFunctional(self.base.convert(base_ctx))
|
|
143
|
+
|
|
144
|
+
def realify(F: Functional) -> Functional:
|
|
145
|
+
"""
|
|
146
|
+
Return ``F`` unchanged on a real domain, realified on a complex one.
|
|
147
|
+
|
|
148
|
+
The idempotent entry point: calling it on an already-real functional is a
|
|
149
|
+
no-op rather than an error, so a caller preparing an objective for a
|
|
150
|
+
real-only optimizer need not branch on the field.
|
|
151
|
+
|
|
152
|
+
Parameters
|
|
153
|
+
----------
|
|
154
|
+
F : Functional
|
|
155
|
+
Functional on a real or complex coordinate space.
|
|
156
|
+
|
|
157
|
+
Returns
|
|
158
|
+
-------
|
|
159
|
+
Functional
|
|
160
|
+
``F`` itself when its domain is real, else a :class:`RealifiedFunctional`
|
|
161
|
+
over stacked real coordinates.
|
|
162
|
+
"""
|
|
163
|
+
if F.domain.field == "real":
|
|
164
|
+
return F
|
|
165
|
+
return RealifiedFunctional(F)
|
|
166
|
+
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Spectral lift ``F(X) = f(lambda(X))`` of a symmetric coordinate functional.
|
|
3
|
+
|
|
4
|
+
For real-valued symmetric ``f`` differentiable at the spectrum of a Hermitian ``X``,
|
|
5
|
+
the Frobenius gradient is ``from_spectrum(grad f(lambda), frame)``.
|
|
6
|
+
Lewis, "Derivatives of Spectral Functions", 1996, Thm 1.1 (convex subgradients: Thm 1.3).
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any, cast
|
|
11
|
+
|
|
12
|
+
from spacecore import (
|
|
13
|
+
CheckLevel,
|
|
14
|
+
Context,
|
|
15
|
+
DenseCoordinateSpace,
|
|
16
|
+
Functional,
|
|
17
|
+
JordanAlgebraSpace,
|
|
18
|
+
LpNormFunctional,
|
|
19
|
+
checked_method,
|
|
20
|
+
)
|
|
21
|
+
from spacecore.functional._base import Domain
|
|
22
|
+
from spacecore.functional.tools._coordinate import _CoordinateFunctional
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def eigenvalue_space(dom: Any, check_level: CheckLevel | None = None) -> Any:
|
|
26
|
+
r"""
|
|
27
|
+
Return the real Euclidean space of shape ``(rank,)`` that the spectrum of ``dom`` lives in.
|
|
28
|
+
|
|
29
|
+
Hermitian eigenvalues are real even with complex entries (Horn & Johnson,
|
|
30
|
+
*Matrix Analysis*, 2nd ed., Thm 4.1.5). Other domains must supply a real
|
|
31
|
+
spectrum for this helper; the rank is read off the zero element.
|
|
32
|
+
|
|
33
|
+
Parameters
|
|
34
|
+
----------
|
|
35
|
+
dom : JordanAlgebraSpace
|
|
36
|
+
Domain whose spectrum is modelled.
|
|
37
|
+
check_level : {"none", "cheap", "standard", "strict"}, optional
|
|
38
|
+
Validation level; ``dom``'s by default.
|
|
39
|
+
|
|
40
|
+
Returns
|
|
41
|
+
-------
|
|
42
|
+
DenseCoordinateSpace
|
|
43
|
+
The eigenvalue space.
|
|
44
|
+
"""
|
|
45
|
+
# The rank is the length of the spectrum of the zero element.
|
|
46
|
+
if not isinstance(dom, JordanAlgebraSpace):
|
|
47
|
+
raise TypeError(
|
|
48
|
+
"eigenvalue_space requires a Jordan-algebra domain with a spectral "
|
|
49
|
+
f"decomposition; got {type(dom).__name__}."
|
|
50
|
+
)
|
|
51
|
+
ops = dom.ops
|
|
52
|
+
rank = int(dom.spectrum(dom.zeros()).shape[0])
|
|
53
|
+
real_ctx = Context(ops, dtype=ops.real_dtype(dom.dtype))
|
|
54
|
+
level = dom.check_level if check_level is None else check_level
|
|
55
|
+
return DenseCoordinateSpace((rank,), real_ctx, check_level=level)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class SpectralFunctional(_CoordinateFunctional[Domain]):
|
|
59
|
+
r"""
|
|
60
|
+
Lift a symmetric coordinate functional onto a Jordan spectrum: :math:`F(X) = f(\lambda(X))`.
|
|
61
|
+
|
|
62
|
+
``LpNormFunctional`` lifts to the Schatten norm and ``NegativeEntropyFunctional`` to
|
|
63
|
+
the negative von Neumann entropy on positive trace-one matrices.
|
|
64
|
+
Horn & Johnson, *Matrix Analysis*, 2nd ed., Thm 7.4.7.2; for Hermitian matrices
|
|
65
|
+
the singular values are the absolute eigenvalues, so the gauge is the p-norm.
|
|
66
|
+
|
|
67
|
+
.. warning::
|
|
68
|
+
|
|
69
|
+
The gradient formula requires permutation-invariant ``f`` and differentiability
|
|
70
|
+
at ``lambda(X)`` (or a convex subgradient). Lewis, "Derivatives of Spectral
|
|
71
|
+
Functions", Thms 1.1 and 1.3. Permutation invariance is not checked; a generic
|
|
72
|
+
:class:`~spacecore_optimize.KLDivergenceFunctional` with unequal targets fails it.
|
|
73
|
+
Elementwise spaces reduce to coordinate differentiation; custom Jordan spaces
|
|
74
|
+
must supply a compatible spectral-gradient reconstruction.
|
|
75
|
+
|
|
76
|
+
Parameters
|
|
77
|
+
----------
|
|
78
|
+
dom : JordanAlgebraSpace
|
|
79
|
+
Domain with a spectral decomposition, e.g. :class:`~spacecore.HermitianSpace`.
|
|
80
|
+
base : Functional
|
|
81
|
+
Symmetric functional on the ``(rank,)`` eigenvalue space; see :func:`eigenvalue_space`.
|
|
82
|
+
ctx : Context, str, or None, optional
|
|
83
|
+
Backend context; resolved from ``dom`` when omitted.
|
|
84
|
+
check_level : {"none", "cheap", "standard", "strict"}, optional
|
|
85
|
+
Validation level.
|
|
86
|
+
|
|
87
|
+
Examples
|
|
88
|
+
--------
|
|
89
|
+
>>> import numpy as np
|
|
90
|
+
>>> import spacecore as sc
|
|
91
|
+
>>> import spacecore_optimize as spo
|
|
92
|
+
>>> ctx = sc.Context(sc.NumpyOps(), dtype=np.float64)
|
|
93
|
+
>>> X = sc.HermitianSpace(2, ctx=ctx)
|
|
94
|
+
>>> S = spo.eigenvalue_space(X)
|
|
95
|
+
>>> vn = spo.SpectralFunctional(X, spo.NegativeEntropyFunctional(S))
|
|
96
|
+
>>> rho = ctx.asarray([[0.5, 0.0], [0.0, 0.5]]) # maximally mixed state
|
|
97
|
+
>>> float(vn.value(rho)) # -log 2, i.e. sum p log p
|
|
98
|
+
-0.6931471805599453
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
_fields = ("dom", "base", "ctx")
|
|
102
|
+
_dynamic = ("base",)
|
|
103
|
+
|
|
104
|
+
def __init__(
|
|
105
|
+
self,
|
|
106
|
+
dom: Domain,
|
|
107
|
+
base: Functional,
|
|
108
|
+
ctx: Context | str | None = None,
|
|
109
|
+
check_level: CheckLevel | None = None,
|
|
110
|
+
) -> None:
|
|
111
|
+
super().__init__(dom, ctx, check_level=check_level)
|
|
112
|
+
# Check the domain has a spectrum and base lives on its eigenvalue space.
|
|
113
|
+
domain = cast(Any, self.domain)
|
|
114
|
+
if not isinstance(domain, JordanAlgebraSpace):
|
|
115
|
+
raise TypeError(
|
|
116
|
+
"SpectralFunctional requires a Jordan-algebra domain with a "
|
|
117
|
+
f"spectral decomposition (e.g. HermitianSpace); got "
|
|
118
|
+
f"{type(domain).__name__}."
|
|
119
|
+
)
|
|
120
|
+
if not isinstance(base, Functional):
|
|
121
|
+
raise TypeError(f"base must be a Functional, got {type(base).__name__}.")
|
|
122
|
+
expected = eigenvalue_space(domain)
|
|
123
|
+
if base.domain.shape != expected.shape:
|
|
124
|
+
raise ValueError(
|
|
125
|
+
f"base domain shape {base.domain.shape} does not match the "
|
|
126
|
+
f"spectrum of {type(domain).__name__} (rank {expected.shape[0]}); "
|
|
127
|
+
f"build it on eigenvalue_space(dom)."
|
|
128
|
+
)
|
|
129
|
+
# Lewis's formula needs the plain coordinate gradient of f.
|
|
130
|
+
if not getattr(base.domain, "is_euclidean", True):
|
|
131
|
+
# Lewis needs the EUCLIDEAN gradient of f. On a Euclidean eigenvalue
|
|
132
|
+
# space base.grad already is that; under any other metric it would be
|
|
133
|
+
# G^-1 df, silently scaling the reconstructed spectral gradient.
|
|
134
|
+
raise ValueError(
|
|
135
|
+
"SpectralFunctional requires a Euclidean eigenvalue space so that "
|
|
136
|
+
"base.grad is the plain coordinate gradient Lewis's formula needs."
|
|
137
|
+
)
|
|
138
|
+
self.base = base
|
|
139
|
+
|
|
140
|
+
@checked_method(in_space="domain", out_space="codomain")
|
|
141
|
+
def value(self, x: Any) -> Any:
|
|
142
|
+
"""Return ``f(lambda(x))``."""
|
|
143
|
+
return self.base.value(cast(Any, self.domain).spectrum(x))
|
|
144
|
+
|
|
145
|
+
def _coordinate_grad(self, x: Any) -> Any:
|
|
146
|
+
"""Return the spectral gradient (Lewis, "Derivatives of Spectral Functions", Thm 1.1)."""
|
|
147
|
+
domain = cast(Any, self.domain)
|
|
148
|
+
eigvals, frame = domain.spectral_decompose(x)
|
|
149
|
+
return domain.from_spectrum(self.base.grad(eigvals), frame)
|
|
150
|
+
|
|
151
|
+
def spectralize(
|
|
152
|
+
dom: Domain,
|
|
153
|
+
make_base: Any,
|
|
154
|
+
ctx: Context | str | None = None,
|
|
155
|
+
check_level: CheckLevel | None = None,
|
|
156
|
+
) -> "SpectralFunctional[Domain]":
|
|
157
|
+
r"""
|
|
158
|
+
Build the spectral lift of ``make_base(eigenvalue_space(dom))`` on ``dom``.
|
|
159
|
+
|
|
160
|
+
Parameters
|
|
161
|
+
----------
|
|
162
|
+
dom : JordanAlgebraSpace
|
|
163
|
+
Domain with a spectral decomposition.
|
|
164
|
+
make_base : callable
|
|
165
|
+
``make_base(eigenvalue_space) -> Functional``, symmetric.
|
|
166
|
+
ctx : Context, str, or None, optional
|
|
167
|
+
Backend context.
|
|
168
|
+
check_level : {"none", "cheap", "standard", "strict"}, optional
|
|
169
|
+
Validation level.
|
|
170
|
+
|
|
171
|
+
Returns
|
|
172
|
+
-------
|
|
173
|
+
SpectralFunctional
|
|
174
|
+
``f(lambda(.))`` on ``dom``.
|
|
175
|
+
|
|
176
|
+
Examples
|
|
177
|
+
--------
|
|
178
|
+
>>> import numpy as np
|
|
179
|
+
>>> import spacecore as sc
|
|
180
|
+
>>> import spacecore_optimize as spo
|
|
181
|
+
>>> ctx = sc.Context(sc.NumpyOps(), dtype=np.float64)
|
|
182
|
+
>>> X = sc.HermitianSpace(2, ctx=ctx)
|
|
183
|
+
>>> vn = spo.spectralize(X, spo.NegativeEntropyFunctional) # negative von Neumann entropy
|
|
184
|
+
>>> float(vn.value(ctx.asarray([[0.5, 0.0], [0.0, 0.5]])))
|
|
185
|
+
-0.6931471805599453
|
|
186
|
+
"""
|
|
187
|
+
base = make_base(eigenvalue_space(dom, check_level=check_level))
|
|
188
|
+
return SpectralFunctional(dom, base, ctx, check_level=check_level)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def NuclearNormFunctional(
|
|
192
|
+
dom: Domain,
|
|
193
|
+
ctx: Context | str | None = None,
|
|
194
|
+
check_level: CheckLevel | None = None,
|
|
195
|
+
) -> "SpectralFunctional[Domain]":
|
|
196
|
+
r"""
|
|
197
|
+
Nuclear norm ``sum_i |lambda_i(X)|``, i.e. the Schatten-1 lift of the 1-norm.
|
|
198
|
+
|
|
199
|
+
Parameters
|
|
200
|
+
----------
|
|
201
|
+
dom : JordanAlgebraSpace
|
|
202
|
+
Domain with a spectral decomposition.
|
|
203
|
+
ctx : Context, str, or None, optional
|
|
204
|
+
Backend context; resolved from ``dom`` when omitted.
|
|
205
|
+
check_level : {"none", "cheap", "standard", "strict"}, optional
|
|
206
|
+
Validation level.
|
|
207
|
+
|
|
208
|
+
Returns
|
|
209
|
+
-------
|
|
210
|
+
SpectralFunctional
|
|
211
|
+
The Schatten-1 lift.
|
|
212
|
+
|
|
213
|
+
Examples
|
|
214
|
+
--------
|
|
215
|
+
>>> import numpy as np
|
|
216
|
+
>>> import spacecore as sc
|
|
217
|
+
>>> import spacecore_optimize as spo
|
|
218
|
+
>>> ctx = sc.Context(sc.NumpyOps(), dtype=np.float64)
|
|
219
|
+
>>> X = sc.HermitianSpace(2, ctx=ctx)
|
|
220
|
+
>>> f = spo.NuclearNormFunctional(X)
|
|
221
|
+
>>> float(f.value(ctx.asarray([[2.0, 0.0], [0.0, -3.0]])))
|
|
222
|
+
5.0
|
|
223
|
+
"""
|
|
224
|
+
return spectralize(
|
|
225
|
+
dom, lambda s: LpNormFunctional(s, 1.0), ctx, check_level=check_level
|
|
226
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: spacecore-optimize
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Optimization on top of SpaceCore: SciPy and optax adapters, and a catalogue of functionals and proximal steps.
|
|
5
|
+
Author: Pavlo Pelikh
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
|
+
Project-URL: Documentation, https://pavlo3p.github.io/spacecore-optimize/
|
|
8
|
+
Project-URL: Source, https://github.com/Pavlo3P/spacecore-optimize
|
|
9
|
+
Project-URL: SpaceCore, https://github.com/Pavlo3P/SpaceCore
|
|
10
|
+
Project-URL: Changelog, https://github.com/Pavlo3P/spacecore-optimize/blob/main/CHANGELOG.md
|
|
11
|
+
Keywords: optimization,proximal,scipy,optax,spacecore
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Science/Research
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Topic :: Scientific/Engineering :: Mathematics
|
|
18
|
+
Requires-Python: >=3.11
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Requires-Dist: spacecore>=0.5.0
|
|
22
|
+
Requires-Dist: numpy>=2.0.0
|
|
23
|
+
Requires-Dist: scipy>=1.17
|
|
24
|
+
Provides-Extra: optax
|
|
25
|
+
Requires-Dist: jax>=0.9.1; extra == "optax"
|
|
26
|
+
Requires-Dist: optax>=0.2; extra == "optax"
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
29
|
+
Provides-Extra: docs
|
|
30
|
+
Requires-Dist: sphinx>=8.0; extra == "docs"
|
|
31
|
+
Requires-Dist: pydata-sphinx-theme>=0.16; extra == "docs"
|
|
32
|
+
Dynamic: license-file
|
|
33
|
+
|
|
34
|
+
# spacecore-optimize
|
|
35
|
+
|
|
36
|
+
Optimization on top of [SpaceCore](https://github.com/Pavlo3P/SpaceCore). SpaceCore supplies
|
|
37
|
+
spaces, geometry, operators, functionals and proximal steps; this package hands them to
|
|
38
|
+
external optimizers and adds a catalogue of common objectives.
|
|
39
|
+
|
|
40
|
+
The one rule it enforces: an optimizer that works on flat coordinates gets the gradient as
|
|
41
|
+
the covector `X.riesz(F.grad(x))`, not the Riesz gradient `F.grad(x)`. On a Euclidean space
|
|
42
|
+
the two coincide; on a weighted or operator geometry only the covector is the derivative
|
|
43
|
+
with respect to the coordinates SciPy and optax update.
|
|
44
|
+
|
|
45
|
+
## Install
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
pip install spacecore-optimize # SciPy adapters and functionals
|
|
49
|
+
pip install "spacecore-optimize[optax]" # plus minimize_optax (JAX backend)
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Requires Python 3.11+ and SpaceCore 0.5.0+.
|
|
53
|
+
|
|
54
|
+
## Example
|
|
55
|
+
|
|
56
|
+
Least squares on a space with a weighted inner product:
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
import numpy as np
|
|
60
|
+
import spacecore as sc
|
|
61
|
+
import spacecore_optimize as spo
|
|
62
|
+
|
|
63
|
+
ctx = sc.Context(sc.NumpyOps(), dtype=np.float64)
|
|
64
|
+
X = sc.DenseCoordinateSpace((3,), ctx, geometry=sc.WeightedInnerProduct(ctx.asarray([1.0, 4.0, 9.0])))
|
|
65
|
+
Y = sc.DenseCoordinateSpace((5,), ctx)
|
|
66
|
+
rng = np.random.default_rng(0)
|
|
67
|
+
A = sc.DenseLinOp(ctx.asarray(rng.standard_normal((5, 3))), X, Y, ctx)
|
|
68
|
+
b = ctx.asarray(rng.standard_normal(5))
|
|
69
|
+
|
|
70
|
+
F = spo.least_squares(A, b) # 1/2 ||A x - b||^2
|
|
71
|
+
result = spo.minimize_scipy(F, X.zeros()) # scipy.optimize.minimize, L-BFGS-B
|
|
72
|
+
x = result.x_element # the minimizer, an element of X
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## What is in it
|
|
76
|
+
|
|
77
|
+
| Name | What it does |
|
|
78
|
+
|---|---|
|
|
79
|
+
| `minimize_scipy`, `line_search_scipy` | Run `scipy.optimize.minimize` / `line_search` on a SpaceCore `Functional`. |
|
|
80
|
+
| `minimize_optax`, `OptaxResult` | A compiled optax loop on a JAX-backed domain, stopping on the dual gradient norm. |
|
|
81
|
+
| `least_squares` | `scale * ‖A x − b‖²` as a quadratic form, optionally weighted. |
|
|
82
|
+
| `NegativeEntropyFunctional`, `KLDivergenceFunctional`, `HuberFunctional` | Coordinate objectives whose gradients follow the domain geometry. |
|
|
83
|
+
| `SpectralFunctional`, `spectralize`, `eigenvalue_space`, `NuclearNormFunctional` | Spectral functions on Jordan-algebra spaces such as Hermitian matrices. |
|
|
84
|
+
| `RealifiedFunctional`, `realify` | A complex-domain functional seen on stacked real coordinates. |
|
|
85
|
+
| `generalized_shrinkage`, `prox_l1`, `prox_l2sq`, `project_nonneg` | Closed-form proximal steps in diagonal geometry. |
|
|
86
|
+
|
|
87
|
+
The norms covered by SpaceCore's own prox rules (`LpNormFunctional`,
|
|
88
|
+
`SquaredL2NormFunctional`, `L1NormFunctional`) live in SpaceCore.
|
|
89
|
+
|
|
90
|
+
## Documentation
|
|
91
|
+
|
|
92
|
+
Published at https://pavlo3p.github.io/spacecore-optimize/. To build locally:
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
pip install -e ".[docs]"
|
|
96
|
+
sphinx-build -W docs docs/_build/html
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## License
|
|
100
|
+
|
|
101
|
+
Apache-2.0.
|