nncg 0.2.0__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.
- nncg/__init__.py +29 -0
- nncg/krylov.py +107 -0
- nncg/solver.py +408 -0
- nncg-0.2.0.dist-info/METADATA +138 -0
- nncg-0.2.0.dist-info/RECORD +7 -0
- nncg-0.2.0.dist-info/WHEEL +4 -0
- nncg-0.2.0.dist-info/licenses/LICENSE +21 -0
nncg/__init__.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Non-negative conjugate gradients.
|
|
2
|
+
|
|
3
|
+
Solves the strictly convex non-negative quadratic program
|
|
4
|
+
``min_{x >= 0} 1/2 x^T A x - b^T x`` (and its equality-augmented variant
|
|
5
|
+
``B x = c``) by wrapping matrix-free conjugate gradients in a primal-dual
|
|
6
|
+
active-set loop with an unconditional finite-termination guarantee — no
|
|
7
|
+
non-degeneracy assumption.
|
|
8
|
+
|
|
9
|
+
Reference implementation of the paper "Non-Negative Conjugate Gradients"
|
|
10
|
+
(Schmelzer & Stoll), whose numerical study doubles as this package's test
|
|
11
|
+
suite: https://github.com/Jebel-Quant/mean_variance_solvers.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import importlib.metadata
|
|
15
|
+
|
|
16
|
+
from .solver import Result, kkt_violation, solve_nnqp, solve_nnqp_eq
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"Result",
|
|
20
|
+
"kkt_violation",
|
|
21
|
+
"solve_nnqp",
|
|
22
|
+
"solve_nnqp_eq",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
__version__ = importlib.metadata.version("nncg")
|
|
27
|
+
except importlib.metadata.PackageNotFoundError:
|
|
28
|
+
# Package metadata not available (development/editable install)
|
|
29
|
+
__version__ = "0.0.0"
|
nncg/krylov.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Conjugate-gradient inner solvers.
|
|
2
|
+
|
|
3
|
+
Plain CG and Jacobi-preconditioned CG for symmetric positive definite (SPD)
|
|
4
|
+
systems, accessed only through a mat-vec callable — the matrix is never
|
|
5
|
+
required explicitly. These are the inner solvers of the active-set loop in
|
|
6
|
+
:mod:`nncg.solver`; their convergence is governed by the spectral condition
|
|
7
|
+
number kappa at the O(sqrt(kappa)) Krylov rate.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections.abc import Callable
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
from cvx.linalg import Vector
|
|
16
|
+
|
|
17
|
+
MatVec = Callable[[Vector], Vector]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def cg(
|
|
21
|
+
matvec: MatVec,
|
|
22
|
+
rhs: Vector,
|
|
23
|
+
tol: float = 1e-8,
|
|
24
|
+
maxit: int = 100_000,
|
|
25
|
+
x0: Vector | None = None,
|
|
26
|
+
) -> tuple[Vector, int]:
|
|
27
|
+
"""Solve an SPD system by conjugate gradients.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
matvec: The action ``v -> A v`` of an SPD operator.
|
|
31
|
+
rhs: Right-hand side ``b``.
|
|
32
|
+
tol: Relative residual stopping tolerance ``||b - A x|| / ||b||``.
|
|
33
|
+
maxit: Iteration cap; the current iterate is returned when it is hit.
|
|
34
|
+
x0: Optional warm start. The initial residual is ``b - A x0``, so a
|
|
35
|
+
good guess cuts the iteration count by the log of the initial
|
|
36
|
+
error.
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
The approximate solution and the number of iterations taken.
|
|
40
|
+
"""
|
|
41
|
+
if x0 is None:
|
|
42
|
+
x = np.zeros_like(rhs)
|
|
43
|
+
r = rhs.copy()
|
|
44
|
+
else:
|
|
45
|
+
x = x0.astype(np.float64, copy=True)
|
|
46
|
+
r = rhs - matvec(x)
|
|
47
|
+
p = r.copy()
|
|
48
|
+
rs = float(r @ r)
|
|
49
|
+
bnorm = float(np.linalg.norm(rhs))
|
|
50
|
+
if bnorm == 0.0:
|
|
51
|
+
return np.zeros_like(rhs), 0
|
|
52
|
+
for it in range(1, maxit + 1):
|
|
53
|
+
ap = matvec(p)
|
|
54
|
+
alpha = rs / float(p @ ap)
|
|
55
|
+
x += alpha * p
|
|
56
|
+
r -= alpha * ap
|
|
57
|
+
rs_new = float(r @ r)
|
|
58
|
+
if np.sqrt(rs_new) / bnorm <= tol:
|
|
59
|
+
return x, it
|
|
60
|
+
p = r + (rs_new / rs) * p
|
|
61
|
+
rs = rs_new
|
|
62
|
+
return x, maxit
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def pcg(
|
|
66
|
+
matvec: MatVec,
|
|
67
|
+
rhs: Vector,
|
|
68
|
+
dinv: Vector,
|
|
69
|
+
tol: float = 1e-8,
|
|
70
|
+
maxit: int = 100_000,
|
|
71
|
+
) -> tuple[Vector, int]:
|
|
72
|
+
"""Solve an SPD system by Jacobi-preconditioned conjugate gradients.
|
|
73
|
+
|
|
74
|
+
The preconditioner is ``M^{-1} = diag(dinv)``; for operators that are a
|
|
75
|
+
well-conditioned core under a bad diagonal scaling, PCG runs at the core's
|
|
76
|
+
condition number regardless of the scaling.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
matvec: The action ``v -> A v`` of an SPD operator.
|
|
80
|
+
rhs: Right-hand side ``b``.
|
|
81
|
+
dinv: Elementwise inverse of the operator's diagonal.
|
|
82
|
+
tol: Relative residual stopping tolerance.
|
|
83
|
+
maxit: Iteration cap; the current iterate is returned when it is hit.
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
The approximate solution and the number of iterations taken.
|
|
87
|
+
"""
|
|
88
|
+
x = np.zeros_like(rhs)
|
|
89
|
+
r = rhs.copy()
|
|
90
|
+
z = dinv * r
|
|
91
|
+
p = z.copy()
|
|
92
|
+
rz = float(r @ z)
|
|
93
|
+
bnorm = float(np.linalg.norm(rhs))
|
|
94
|
+
if bnorm == 0.0:
|
|
95
|
+
return x, 0
|
|
96
|
+
for it in range(1, maxit + 1):
|
|
97
|
+
ap = matvec(p)
|
|
98
|
+
alpha = rz / float(p @ ap)
|
|
99
|
+
x += alpha * p
|
|
100
|
+
r -= alpha * ap
|
|
101
|
+
if float(np.linalg.norm(r)) / bnorm <= tol:
|
|
102
|
+
return x, it
|
|
103
|
+
z = dinv * r
|
|
104
|
+
rz_new = float(r @ z)
|
|
105
|
+
p = z + (rz_new / rz) * p
|
|
106
|
+
rz = rz_new
|
|
107
|
+
return x, maxit
|
nncg/solver.py
ADDED
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
"""Non-negative conjugate gradients: the active-set / block-principal-pivoting loop.
|
|
2
|
+
|
|
3
|
+
Solves the strictly convex non-negative quadratic program
|
|
4
|
+
|
|
5
|
+
min_{x >= 0} 1/2 x^T A x - b^T x, A symmetric positive definite,
|
|
6
|
+
|
|
7
|
+
and its equality-augmented variant with a general linear system ``B x = c``,
|
|
8
|
+
by wrapping a matrix-free conjugate-gradient inner solver in a primal-dual
|
|
9
|
+
active-set outer loop. The working-set toggles are the principal pivots of the
|
|
10
|
+
linear complementarity problem LCP(A, -b); guarding the fast block-pivot path
|
|
11
|
+
with a least-index Bland fallback gives unconditional finite termination at
|
|
12
|
+
the unique global minimiser — no non-degeneracy assumption (Theorem 5.1 of the
|
|
13
|
+
accompanying paper). See https://github.com/Jebel-Quant/mean_variance_solvers.
|
|
14
|
+
|
|
15
|
+
The quadratic term enters as a :class:`cvx.linalg.SymmetricOperator`, accessed
|
|
16
|
+
only through block products: ``apply_free`` drives the CG inner solves,
|
|
17
|
+
``matvec`` the reduced gradient, and ``solve_free`` the optional direct inner
|
|
18
|
+
solver. Wrap an explicit SPD array in ``DenseOperator``; for the Gram case
|
|
19
|
+
``A = M^T M + ridge I`` pass ``GramOperator(M, ridge)`` and the ``n x n``
|
|
20
|
+
matrix is never formed.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from collections.abc import Callable
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
|
|
28
|
+
import numpy as np
|
|
29
|
+
from cvx.linalg import Matrix, SymmetricOperator, Vector, cholesky_solve
|
|
30
|
+
from numpy.typing import NDArray
|
|
31
|
+
|
|
32
|
+
from .krylov import MatVec, cg, pcg
|
|
33
|
+
|
|
34
|
+
SubSolve = Callable[[NDArray[np.int_], "Vector | None"], "tuple[Vector, Vector | None, int]"]
|
|
35
|
+
"""Subproblem solve on a free set: ``(idx, x0) -> (x_F, lam, inner_iters)``."""
|
|
36
|
+
|
|
37
|
+
ReducedGradient = Callable[["Vector", "Vector | None"], "Vector"]
|
|
38
|
+
"""Reduced gradient of the subproblem: ``(x, lam) -> s``."""
|
|
39
|
+
|
|
40
|
+
_NEEDS_OPERATOR = (
|
|
41
|
+
"the quadratic term must be a cvx.linalg.SymmetricOperator: wrap a dense SPD "
|
|
42
|
+
"array in DenseOperator(a), or pass GramOperator(M, ridge) for A = M'M + ridge*I"
|
|
43
|
+
)
|
|
44
|
+
_RCOND_MIN = 1e-12 # matches cvx-linalg's DEFAULT_COND_THRESHOLD of 1e12
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _check_dimension(op: SymmetricOperator, b: Vector) -> None:
|
|
48
|
+
"""Check that the operator and the linear term agree in dimension.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
op: The symmetric operator ``A``.
|
|
52
|
+
b: The linear term ``b``.
|
|
53
|
+
|
|
54
|
+
Raises:
|
|
55
|
+
ValueError: When ``op.n != len(b)``.
|
|
56
|
+
"""
|
|
57
|
+
if op.n != len(b):
|
|
58
|
+
msg = f"operator dimension {op.n} does not match len(b) = {len(b)}"
|
|
59
|
+
raise ValueError(msg)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _require_operator(a: object) -> SymmetricOperator:
|
|
63
|
+
"""Validate that the quadratic term is a symmetric operator.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
a: The candidate quadratic term.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
*a* unchanged when it is a :class:`cvx.linalg.SymmetricOperator`.
|
|
70
|
+
|
|
71
|
+
Raises:
|
|
72
|
+
TypeError: When *a* is anything else (e.g. a dense array).
|
|
73
|
+
"""
|
|
74
|
+
if not isinstance(a, SymmetricOperator):
|
|
75
|
+
raise TypeError(_NEEDS_OPERATOR)
|
|
76
|
+
return a
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _free_matvec(op: SymmetricOperator, idx: NDArray[np.int_]) -> MatVec:
|
|
80
|
+
"""Return the free-block action ``v -> A[F, F] v`` of the operator.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
op: The symmetric operator ``A``.
|
|
84
|
+
idx: Integer positions of the free set ``F``.
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
A callable computing ``op.apply_free(idx, v)``; the reduced matrix is
|
|
88
|
+
never materialised.
|
|
89
|
+
"""
|
|
90
|
+
return lambda v: op.apply_free(idx, v)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass(frozen=True)
|
|
94
|
+
class Result:
|
|
95
|
+
"""Outcome of an active-set solve.
|
|
96
|
+
|
|
97
|
+
Attributes:
|
|
98
|
+
x: The minimiser (or the final iterate if ``converged`` is False).
|
|
99
|
+
outer: Number of outer active-set steps taken.
|
|
100
|
+
inner: Total inner (CG/PCG) iterations across all outer steps; each
|
|
101
|
+
direct inner solve counts as one.
|
|
102
|
+
fallback: Number of least-index Bland fallback pivots taken.
|
|
103
|
+
converged: True when the KKT exit was reached; False when an
|
|
104
|
+
``max_outer`` cap stopped the loop first.
|
|
105
|
+
free: Boolean mask of the final free set.
|
|
106
|
+
lam: Multipliers of the equality constraints (equality-augmented
|
|
107
|
+
solves only; None otherwise).
|
|
108
|
+
traj: The sequence of visited free sets as index tuples when
|
|
109
|
+
trajectory tracking was requested; None otherwise.
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
x: Vector
|
|
113
|
+
outer: int
|
|
114
|
+
inner: int
|
|
115
|
+
fallback: int
|
|
116
|
+
converged: bool
|
|
117
|
+
free: NDArray[np.bool_]
|
|
118
|
+
lam: Vector | None = None
|
|
119
|
+
traj: list[tuple[int, ...]] | None = None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _active_set_loop(
|
|
123
|
+
n: int,
|
|
124
|
+
sub_solve: SubSolve,
|
|
125
|
+
reduced_gradient: ReducedGradient,
|
|
126
|
+
tol: float,
|
|
127
|
+
p_max: int,
|
|
128
|
+
track: bool = False,
|
|
129
|
+
max_outer: int | None = None,
|
|
130
|
+
warm: tuple[NDArray[np.bool_], Vector] | None = None,
|
|
131
|
+
) -> Result:
|
|
132
|
+
"""Run the guarded primal-dual active-set loop shared by both solvers.
|
|
133
|
+
|
|
134
|
+
The driver owns everything the termination proof depends on: the primal
|
|
135
|
+
and dual violator tests, the batch exchange with its patience counter,
|
|
136
|
+
and the least-index Bland fallback. What is solved on each free set — a
|
|
137
|
+
single reduced system, or the equality-augmented saddle system — enters
|
|
138
|
+
through the ``sub_solve`` callback, with ``reduced_gradient`` supplying
|
|
139
|
+
the matching dual test quantity.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
n: Problem dimension.
|
|
143
|
+
sub_solve: Callback ``(idx, x0) -> (x_F, lam, inner_iters)`` solving
|
|
144
|
+
the subproblem on the free set ``idx``. ``x0`` is a warm inner
|
|
145
|
+
guess restricted to ``idx`` (None on a cold start); ``lam`` are
|
|
146
|
+
the equality multipliers (None for the bound-only problem).
|
|
147
|
+
reduced_gradient: Callback ``(x, lam) -> s`` computing the reduced
|
|
148
|
+
gradient that drives the dual violator test.
|
|
149
|
+
tol: Threshold of the primal and dual violator tests.
|
|
150
|
+
p_max: Patience budget — non-improving batch steps tolerated before a
|
|
151
|
+
fallback pivot. Any value gives finite termination.
|
|
152
|
+
track: Record the free-set trajectory in ``Result.traj``.
|
|
153
|
+
max_outer: Optional cap on outer steps; when hit, the current iterate
|
|
154
|
+
is returned with ``converged=False``.
|
|
155
|
+
warm: Optional ``(free_mask, x_prev)`` pair from a previous solve.
|
|
156
|
+
Starts the loop from that free set and seeds every subproblem
|
|
157
|
+
solve from the newest iterate.
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
A :class:`Result`; ``lam`` is whatever the last subproblem returned.
|
|
161
|
+
"""
|
|
162
|
+
if warm is None:
|
|
163
|
+
free = np.ones(n, dtype=bool) # F = {1..n} initially
|
|
164
|
+
x_guess: Vector | None = None
|
|
165
|
+
else:
|
|
166
|
+
free = warm[0].copy()
|
|
167
|
+
x_guess = warm[1]
|
|
168
|
+
x = np.zeros(n)
|
|
169
|
+
lam: Vector | None = None
|
|
170
|
+
n_bar = n + 1
|
|
171
|
+
patience = p_max
|
|
172
|
+
outer = inner_total = fallback = 0
|
|
173
|
+
traj: list[tuple[int, ...]] | None = [] if track else None
|
|
174
|
+
converged = True
|
|
175
|
+
|
|
176
|
+
while True:
|
|
177
|
+
if max_outer is not None and outer >= max_outer:
|
|
178
|
+
converged = False
|
|
179
|
+
break
|
|
180
|
+
idx = np.flatnonzero(free)
|
|
181
|
+
if traj is not None:
|
|
182
|
+
traj.append(tuple(idx.tolist()))
|
|
183
|
+
x0 = x_guess[idx] if x_guess is not None else None
|
|
184
|
+
xf, lam, k_step = sub_solve(idx, x0)
|
|
185
|
+
outer += 1
|
|
186
|
+
inner_total += k_step
|
|
187
|
+
|
|
188
|
+
x = np.zeros(n)
|
|
189
|
+
x[idx] = xf
|
|
190
|
+
if x_guess is not None:
|
|
191
|
+
x_guess = x # warm mode: newest iterate seeds the next reduced solve
|
|
192
|
+
s = reduced_gradient(x, lam)
|
|
193
|
+
|
|
194
|
+
prim = np.flatnonzero(free & (x < -tol)) # D: free but negative
|
|
195
|
+
dual = np.flatnonzero((~free) & (s < -tol)) # V: bound but s < 0
|
|
196
|
+
viol = np.concatenate([prim, dual])
|
|
197
|
+
n_viol = viol.size
|
|
198
|
+
if n_viol == 0:
|
|
199
|
+
break # KKT satisfied -> unique global minimiser
|
|
200
|
+
|
|
201
|
+
if n_viol < n_bar or patience > 0: # fast path: progress, or patience remains
|
|
202
|
+
if n_viol < n_bar:
|
|
203
|
+
n_bar = n_viol
|
|
204
|
+
patience = p_max
|
|
205
|
+
else:
|
|
206
|
+
patience -= 1
|
|
207
|
+
free[prim] = False # batch exchange: drop all D, add all V
|
|
208
|
+
free[dual] = True
|
|
209
|
+
else: # anti-cycling fallback: single Bland least-index pivot
|
|
210
|
+
fallback += 1
|
|
211
|
+
i_star = int(viol.min())
|
|
212
|
+
free[i_star] = not free[i_star]
|
|
213
|
+
|
|
214
|
+
return Result(
|
|
215
|
+
x=x,
|
|
216
|
+
outer=outer,
|
|
217
|
+
inner=inner_total,
|
|
218
|
+
fallback=fallback,
|
|
219
|
+
converged=converged,
|
|
220
|
+
free=free,
|
|
221
|
+
lam=lam,
|
|
222
|
+
traj=traj,
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def kkt_violation(a: SymmetricOperator, b: Vector, x: Vector) -> float:
|
|
227
|
+
"""Maximum violation of the KKT system of ``min_{x>=0} 1/2 x'Ax - b'x``.
|
|
228
|
+
|
|
229
|
+
Args:
|
|
230
|
+
a: The SPD operator ``A`` (a :class:`cvx.linalg.SymmetricOperator`).
|
|
231
|
+
b: The linear term ``b``.
|
|
232
|
+
x: Candidate solution.
|
|
233
|
+
|
|
234
|
+
Returns:
|
|
235
|
+
``max`` of the negativity violations of ``x`` and of the reduced
|
|
236
|
+
gradient ``s = A x - b``, and of the complementarity products
|
|
237
|
+
``|x_i s_i|``. Zero certifies the unique global minimiser.
|
|
238
|
+
"""
|
|
239
|
+
op = _require_operator(a)
|
|
240
|
+
_check_dimension(op, b)
|
|
241
|
+
s = op.matvec(x) - b
|
|
242
|
+
return float(
|
|
243
|
+
max(
|
|
244
|
+
np.max(-x, initial=0.0),
|
|
245
|
+
np.max(-s, initial=0.0),
|
|
246
|
+
np.max(np.abs(x * s), initial=0.0),
|
|
247
|
+
)
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def solve_nnqp(
|
|
252
|
+
a: SymmetricOperator,
|
|
253
|
+
b: Vector,
|
|
254
|
+
tol: float = 1e-8,
|
|
255
|
+
cg_tol: float = 1e-10,
|
|
256
|
+
p_max: int = 3,
|
|
257
|
+
inner: str = "cg",
|
|
258
|
+
track: bool = False,
|
|
259
|
+
cg_maxit: int = 100_000,
|
|
260
|
+
max_outer: int | None = None,
|
|
261
|
+
warm: tuple[NDArray[np.bool_], Vector] | None = None,
|
|
262
|
+
) -> Result:
|
|
263
|
+
"""Minimise ``1/2 x^T A x - b^T x`` over ``x >= 0`` by the active-set loop.
|
|
264
|
+
|
|
265
|
+
Each free-block solve is matrix-free CG on ``v -> op.apply_free(F, v)``;
|
|
266
|
+
the reduced matrix is never materialised and ``A`` is never refactorised.
|
|
267
|
+
The batch block-pivot fast path is guarded by a least-index Bland
|
|
268
|
+
fallback, so termination at the unique global minimiser is unconditional
|
|
269
|
+
— no non-degeneracy assumption.
|
|
270
|
+
|
|
271
|
+
Args:
|
|
272
|
+
a: The SPD operator ``A`` (a :class:`cvx.linalg.SymmetricOperator`) —
|
|
273
|
+
``DenseOperator`` for an explicit array, ``GramOperator(M, ridge)``
|
|
274
|
+
for ``A = M^T M + ridge I`` whose Gram matrix is never formed.
|
|
275
|
+
b: The linear term ``b``.
|
|
276
|
+
tol: Threshold of the primal and dual violator tests.
|
|
277
|
+
cg_tol: Relative residual tolerance of the inner solves. Keep it a
|
|
278
|
+
couple of orders below ``tol`` so the inexact loop makes the same
|
|
279
|
+
sign decisions as the exact one (Lemma 5.1 of the paper).
|
|
280
|
+
p_max: Patience budget — non-improving batch steps tolerated before a
|
|
281
|
+
fallback pivot. Any value gives finite termination.
|
|
282
|
+
inner: ``"cg"`` (matrix-free), ``"pcg"`` (Jacobi-preconditioned from
|
|
283
|
+
``op.diag``), or ``"exact"`` (direct solve of each free block via
|
|
284
|
+
``op.solve_free``). Match the inner solver to the backend: pick
|
|
285
|
+
``"exact"`` when ``solve_free`` is structured and cheap — e.g.
|
|
286
|
+
``FactorOperator``'s Woodbury solve at ``O(|F| r^2)`` — and CG
|
|
287
|
+
when only products are cheap (large dense ``A``, Gram factors
|
|
288
|
+
with many rows).
|
|
289
|
+
track: Record the free-set trajectory in ``Result.traj``.
|
|
290
|
+
cg_maxit: Iteration cap per inner solve.
|
|
291
|
+
max_outer: Optional cap on outer steps; when hit, the current iterate
|
|
292
|
+
is returned with ``converged=False``.
|
|
293
|
+
warm: Optional ``(free_mask, x_prev)`` pair from a previous solve.
|
|
294
|
+
Starts the loop from that free set and warm-starts every CG call
|
|
295
|
+
from the newest iterate — across a support-stable parameter step
|
|
296
|
+
the loop then terminates in a single outer step.
|
|
297
|
+
|
|
298
|
+
Returns:
|
|
299
|
+
A :class:`Result`; ``converged`` is True iff the KKT system was
|
|
300
|
+
satisfied to ``tol``, which certifies the unique global minimiser.
|
|
301
|
+
|
|
302
|
+
Raises:
|
|
303
|
+
TypeError: When ``a`` is not a :class:`cvx.linalg.SymmetricOperator`.
|
|
304
|
+
ValueError: When the operator dimension does not match ``len(b)``, or
|
|
305
|
+
when ``inner="exact"`` meets a numerically singular free block
|
|
306
|
+
(``op.rcond_free`` below 1e-12) — ``A`` is then not positive
|
|
307
|
+
definite on that free set; add a ridge.
|
|
308
|
+
NotImplementedError: When ``inner="pcg"`` meets a backend that does
|
|
309
|
+
not expose ``diag`` (propagated from ``cvx.linalg``).
|
|
310
|
+
"""
|
|
311
|
+
op = _require_operator(a)
|
|
312
|
+
_check_dimension(op, b)
|
|
313
|
+
dinv: Vector | None = None # Jacobi preconditioner, read off op.diag on first use
|
|
314
|
+
|
|
315
|
+
def sub_solve(idx: NDArray[np.int_], x0: Vector | None) -> tuple[Vector, Vector | None, int]:
|
|
316
|
+
"""Solve the reduced system ``A_F x_F = b_F`` with the chosen inner solver."""
|
|
317
|
+
nonlocal dinv
|
|
318
|
+
if inner == "exact":
|
|
319
|
+
rcond = op.rcond_free(idx)
|
|
320
|
+
if rcond < _RCOND_MIN:
|
|
321
|
+
msg = f"free block of size {idx.size} is numerically singular (rcond={rcond:.2e})"
|
|
322
|
+
raise ValueError(msg)
|
|
323
|
+
return op.solve_free(idx, b[idx]), None, 1
|
|
324
|
+
if inner == "pcg":
|
|
325
|
+
if dinv is None:
|
|
326
|
+
dinv = 1.0 / op.diag
|
|
327
|
+
xf, k_step = pcg(_free_matvec(op, idx), b[idx], dinv[idx], tol=cg_tol, maxit=cg_maxit)
|
|
328
|
+
return xf, None, k_step
|
|
329
|
+
xf, k_step = cg(_free_matvec(op, idx), b[idx], tol=cg_tol, maxit=cg_maxit, x0=x0)
|
|
330
|
+
return xf, None, k_step
|
|
331
|
+
|
|
332
|
+
def reduced_gradient(x: Vector, lam: Vector | None) -> Vector: # noqa: ARG001
|
|
333
|
+
"""Return the reduced gradient ``s = A x - b``."""
|
|
334
|
+
return op.matvec(x) - b
|
|
335
|
+
|
|
336
|
+
return _active_set_loop(
|
|
337
|
+
len(b),
|
|
338
|
+
sub_solve,
|
|
339
|
+
reduced_gradient,
|
|
340
|
+
tol=tol,
|
|
341
|
+
p_max=p_max,
|
|
342
|
+
track=track,
|
|
343
|
+
max_outer=max_outer,
|
|
344
|
+
warm=warm,
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def solve_nnqp_eq(
|
|
349
|
+
a: SymmetricOperator,
|
|
350
|
+
b: Vector,
|
|
351
|
+
b_eq: Matrix,
|
|
352
|
+
c_eq: Vector,
|
|
353
|
+
tol: float = 1e-8,
|
|
354
|
+
cg_tol: float = 1e-10,
|
|
355
|
+
p_max: int = 3,
|
|
356
|
+
) -> Result:
|
|
357
|
+
"""Solve ``min 1/2 x^T A x - b^T x`` subject to ``x >= 0`` and ``B x = c``.
|
|
358
|
+
|
|
359
|
+
On each free set the saddle system is solved by eliminating the multiplier
|
|
360
|
+
``lambda`` in R^p through the p-by-p Schur complement
|
|
361
|
+
``S = B_F A_F^{-1} B_F^T``: the ``p + 1`` right-hand sides share the
|
|
362
|
+
operator ``A_F`` and are each one matrix-free CG solve, then
|
|
363
|
+
``S lambda = c - B_F v0`` fixes the multipliers in closed form. The single
|
|
364
|
+
normalisation ``1^T x = beta`` is the ``p = 1`` case. ``B`` must have full
|
|
365
|
+
row rank on the visited free sets (automatic for ``p = 1``).
|
|
366
|
+
|
|
367
|
+
Args:
|
|
368
|
+
a: The SPD operator ``A`` (a :class:`cvx.linalg.SymmetricOperator`).
|
|
369
|
+
b: The linear term ``b``.
|
|
370
|
+
b_eq: Equality matrix ``B`` of shape ``(p, n)``, full row rank.
|
|
371
|
+
c_eq: Equality right-hand side ``c`` of shape ``(p,)``.
|
|
372
|
+
tol: Threshold of the primal and dual violator tests.
|
|
373
|
+
cg_tol: Relative residual tolerance of the inner CG solves.
|
|
374
|
+
p_max: Patience budget of the batch fast path.
|
|
375
|
+
|
|
376
|
+
Returns:
|
|
377
|
+
A :class:`Result` with the multipliers in ``lam``. The reduced
|
|
378
|
+
gradient underlying the dual test is ``s = A x - b - B^T lam``.
|
|
379
|
+
|
|
380
|
+
Raises:
|
|
381
|
+
TypeError: When ``a`` is not a :class:`cvx.linalg.SymmetricOperator`.
|
|
382
|
+
ValueError: When the operator dimension does not match ``len(b)``.
|
|
383
|
+
"""
|
|
384
|
+
op = _require_operator(a)
|
|
385
|
+
_check_dimension(op, b)
|
|
386
|
+
p = b_eq.shape[0]
|
|
387
|
+
|
|
388
|
+
def sub_solve(idx: NDArray[np.int_], x0: Vector | None) -> tuple[Vector, Vector | None, int]: # noqa: ARG001
|
|
389
|
+
"""Solve the saddle system on the free set via the p-by-p Schur complement."""
|
|
390
|
+
matvec_f = _free_matvec(op, idx)
|
|
391
|
+
b_f = b_eq[:, idx]
|
|
392
|
+
v0, k0 = cg(matvec_f, b[idx], tol=cg_tol)
|
|
393
|
+
v1 = np.zeros((idx.size, p))
|
|
394
|
+
k_cols = 0
|
|
395
|
+
for j in range(p):
|
|
396
|
+
v1[:, j], kj = cg(matvec_f, b_f[j], tol=cg_tol)
|
|
397
|
+
k_cols += kj
|
|
398
|
+
schur = b_f @ v1 # p-by-p Schur complement, SPD
|
|
399
|
+
lam = cholesky_solve(schur, c_eq - b_f @ v0)
|
|
400
|
+
xf = v0 + v1 @ lam # x_F = A_F^{-1}(b_F + B_F^T lambda)
|
|
401
|
+
return xf, lam, k0 + k_cols
|
|
402
|
+
|
|
403
|
+
def reduced_gradient(x: Vector, lam: Vector | None) -> Vector:
|
|
404
|
+
"""Return the constrained reduced gradient ``s = A x - b - B^T lam``."""
|
|
405
|
+
correction = b_eq.T @ lam if lam is not None else np.zeros_like(b)
|
|
406
|
+
return op.matvec(x) - b - correction
|
|
407
|
+
|
|
408
|
+
return _active_set_loop(len(b), sub_solve, reduced_gradient, tol=tol, p_max=p_max)
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nncg
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Non-negative conjugate gradients: bound-constrained SPD quadratics by a guarded active-set loop
|
|
5
|
+
Project-URL: Homepage, https://github.com/Jebel-Quant/nncg
|
|
6
|
+
Project-URL: Repository, https://github.com/Jebel-Quant/nncg
|
|
7
|
+
Project-URL: Paper, https://github.com/Jebel-Quant/mean_variance_solvers
|
|
8
|
+
Author-email: Thomas Schmelzer <thomas.schmelzer@gmail.com>, Martin Stoll <martin.stoll@mathematik.tu-chemnitz.de>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Requires-Python: >=3.11
|
|
16
|
+
Requires-Dist: cvx-linalg>=0.9.5
|
|
17
|
+
Requires-Dist: numpy>=2.0.0
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
<div align="center" markdown="1">
|
|
21
|
+
|
|
22
|
+
# ➕ nncg — Non-Negative Conjugate Gradients
|
|
23
|
+
|
|
24
|
+
[](https://github.com/Jebel-Quant/nncg/actions/workflows/rhiza_ci.yml)
|
|
25
|
+
[](https://jebel-quant.github.io/nncg/reports/html-coverage/)
|
|
26
|
+
[](https://github.com/Jebel-Quant/nncg/blob/main/pyproject.toml)
|
|
27
|
+
[](LICENSE)
|
|
28
|
+
[](https://github.com/Jebel-Quant/nncg/actions/workflows/rhiza_codeql.yml)
|
|
29
|
+
[](https://github.com/jebel-quant/rhiza)
|
|
30
|
+
[](https://github.com/Jebel-Quant/mean_variance_solvers)
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
**Quick Links:**
|
|
35
|
+
[📄 Paper](https://github.com/Jebel-Quant/mean_variance_solvers) •
|
|
36
|
+
[🐛 Report Bug](https://github.com/Jebel-Quant/nncg/issues) •
|
|
37
|
+
[💡 Request Feature](https://github.com/Jebel-Quant/nncg/issues)
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
</div>
|
|
42
|
+
|
|
43
|
+
## 📋 Overview
|
|
44
|
+
|
|
45
|
+
`nncg` solves the strictly convex non-negative quadratic program
|
|
46
|
+
|
|
47
|
+
$$\min_{x \geq 0}\ \tfrac{1}{2} x^\top A x - b^\top x, \qquad A \succ 0,$$
|
|
48
|
+
|
|
49
|
+
and its equality-augmented variant with a general linear system $Bx = c$, by
|
|
50
|
+
wrapping **matrix-free conjugate gradients** in a **primal-dual active-set
|
|
51
|
+
loop**. The working-set toggles are the principal pivots of the linear
|
|
52
|
+
complementarity problem $\mathrm{LCP}(A, -b)$; guarding the fast block-pivot
|
|
53
|
+
path with a least-index Bland fallback gives **unconditional finite
|
|
54
|
+
termination** at the unique global minimiser — no non-degeneracy assumption.
|
|
55
|
+
|
|
56
|
+
This is the reference implementation of the paper *Non-Negative Conjugate
|
|
57
|
+
Gradients* (Schmelzer & Stoll), developed in
|
|
58
|
+
[Jebel-Quant/mean_variance_solvers](https://github.com/Jebel-Quant/mean_variance_solvers).
|
|
59
|
+
The paper's numerical study doubles as this package's test suite: planted-optimum
|
|
60
|
+
recovery across condition numbers, the equality-augmented solve for
|
|
61
|
+
$p \in \{1, 3, 8\}$, CG-vs-exact free-set trajectory agreement (the inexactness
|
|
62
|
+
lemma), warm-started parameter sweeps, and the adversarial anti-correlated
|
|
63
|
+
family on which the *unguarded* batch path provably cycles and the fallback
|
|
64
|
+
terminates.
|
|
65
|
+
|
|
66
|
+
The quadratic term enters as a `cvx.linalg.SymmetricOperator`: wrap an
|
|
67
|
+
explicit SPD array in `DenseOperator`. When $A = M^\top M$ is a Gram matrix,
|
|
68
|
+
pass `GramOperator(M, ridge)` and the inner solves need only products with
|
|
69
|
+
$M$ — the $n \times n$ matrix is never formed and working memory is $O(n)$.
|
|
70
|
+
|
|
71
|
+
## 📦 Installation
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
pip install nncg
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## 🚀 Quickstart
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
import numpy as np
|
|
81
|
+
from cvx.linalg import DenseOperator, GramOperator
|
|
82
|
+
from nncg import kkt_violation, solve_nnqp, solve_nnqp_eq
|
|
83
|
+
|
|
84
|
+
# a random SPD problem with condition number 1e4
|
|
85
|
+
rng = np.random.default_rng(0)
|
|
86
|
+
Q, _ = np.linalg.qr(rng.standard_normal((200, 200)))
|
|
87
|
+
A = (Q * np.geomspace(1.0, 1e4, 200)) @ Q.T
|
|
88
|
+
b = rng.standard_normal(200)
|
|
89
|
+
op = DenseOperator(A) # the solvers take a SymmetricOperator
|
|
90
|
+
|
|
91
|
+
res = solve_nnqp(op, b)
|
|
92
|
+
assert res.converged # stopped on the KKT certificate
|
|
93
|
+
assert kkt_violation(op, b, res.x) < 1e-6 # zero certifies the global minimiser
|
|
94
|
+
|
|
95
|
+
# equality-augmented: minimise subject to x >= 0 and B x = c
|
|
96
|
+
B = np.ones((1, 200)) # p = 1: the budget 1'x = 1
|
|
97
|
+
res_eq = solve_nnqp_eq(op, b, B, np.array([1.0]))
|
|
98
|
+
assert res_eq.lam.shape == (1,) # multiplier, via a p-by-p Schur solve
|
|
99
|
+
|
|
100
|
+
# warm-start a parametric sweep: support-stable steps take ONE outer step
|
|
101
|
+
res2 = solve_nnqp(op, b + 1e-4, warm=(res.free, res.x))
|
|
102
|
+
|
|
103
|
+
# Gram-structured: A = M'M + I only through products with M — never formed
|
|
104
|
+
M = np.random.default_rng(1).standard_normal((50, 200))
|
|
105
|
+
res_g = solve_nnqp(GramOperator(M, ridge=1.0), M.T @ np.ones(50))
|
|
106
|
+
assert res_g.converged
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## 🔬 The algorithm in one paragraph
|
|
110
|
+
|
|
111
|
+
Fix a working set of *free* variables and solve the unconstrained reduced SPD
|
|
112
|
+
system by CG (matrix-free, $O(\sqrt{\kappa})$ Krylov rate). Push any free
|
|
113
|
+
variable that returns negative to its bound (primal step); release any bound
|
|
114
|
+
variable whose reduced gradient is negative (dual step); repeat. Batch
|
|
115
|
+
exchanges are fast but can cycle; a patience counter falls back to Murty's
|
|
116
|
+
least-index single pivot, which cannot — hence finite termination without any
|
|
117
|
+
non-degeneracy hypothesis, and the fallback is provably necessary: on
|
|
118
|
+
anti-correlated designs (the `make_adversarial` family in the test suite's
|
|
119
|
+
`tests/problems.py`) the unguarded batch path revisits a previously seen
|
|
120
|
+
working set and loops forever.
|
|
121
|
+
|
|
122
|
+
## 📖 Citation
|
|
123
|
+
|
|
124
|
+
If you use this package in academic work, please cite the paper:
|
|
125
|
+
|
|
126
|
+
```bibtex
|
|
127
|
+
@techreport{schmelzer2026nncg,
|
|
128
|
+
title = {Non-Negative Conjugate Gradients},
|
|
129
|
+
author = {Schmelzer, Thomas and Stoll, Martin},
|
|
130
|
+
year = {2026},
|
|
131
|
+
institution = {Jebel Quant Research and TU Chemnitz},
|
|
132
|
+
url = {https://github.com/Jebel-Quant/mean_variance_solvers},
|
|
133
|
+
}
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## ⚖️ License
|
|
137
|
+
|
|
138
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
nncg/__init__.py,sha256=_Vu53ZS8vkiu3f6CAev_IEJN-3A3Xe8xE25Q87STBsY,944
|
|
2
|
+
nncg/krylov.py,sha256=CalDq4BEHwxcN0qLdVSSOQ_W3CAhwMQ0-vdzKsagQSo,3208
|
|
3
|
+
nncg/solver.py,sha256=mSO5lAXPx4V1yR-_c-a3VUPH6FLFDxNBRcIPGzZiqR8,16237
|
|
4
|
+
nncg-0.2.0.dist-info/METADATA,sha256=_AmO9NTjT8POvn78T7gigQWpZbxT3AJBSWQXkUjCqVo,6192
|
|
5
|
+
nncg-0.2.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
6
|
+
nncg-0.2.0.dist-info/licenses/LICENSE,sha256=16_KyE-yX_95bzrVyE3L9P01uop_HN6rbLOIp2tozes,1068
|
|
7
|
+
nncg-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jebel Quant
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|