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 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
+ [![CI](https://github.com/Jebel-Quant/nncg/actions/workflows/rhiza_ci.yml/badge.svg)](https://github.com/Jebel-Quant/nncg/actions/workflows/rhiza_ci.yml)
25
+ [![Coverage](https://jebel-quant.github.io/nncg/coverage-badge.svg)](https://jebel-quant.github.io/nncg/reports/html-coverage/)
26
+ [![Python](https://img.shields.io/badge/python-3.11%2B-blue)](https://github.com/Jebel-Quant/nncg/blob/main/pyproject.toml)
27
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
28
+ [![CodeQL](https://github.com/Jebel-Quant/nncg/actions/workflows/rhiza_codeql.yml/badge.svg)](https://github.com/Jebel-Quant/nncg/actions/workflows/rhiza_codeql.yml)
29
+ [![Rhiza](https://img.shields.io/badge/dynamic/yaml?url=https%3A%2F%2Fraw.githubusercontent.com%2FJebel-Quant%2Fnncg%2Fmain%2F.rhiza%2Ftemplate.yml&query=%24.ref&label=rhiza)](https://github.com/jebel-quant/rhiza)
30
+ [![Paper](https://img.shields.io/badge/paper-Non--Negative_Conjugate_Gradients-red?logo=adobeacrobatreader)](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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.