API reference¶
Every name exported from the nncg namespace, grouped the way you reach for
them: the one-call wrappers first, then the two solver families they wrap
(the active-set loop and MPRGP), then the KKT certificate that both are
judged by, and finally the inner solvers and the matrix-free Krylov core the
active-set loop runs on.
Private helpers are omitted. The planted-optimum problem generators live
outside the installed package, in the repository's tests/problems.py.
One-call wrappers¶
Logic-free shortcuts over the solver classes below: they wrap a plain array in
a DenseOperator and resolve the inner string. Reach for these first.
nncg.api
¶
One-call convenience entry points over the core solvers.
:func:solve_nnqp and :func:solve_nnqp_eq compose the three pieces of the
core API — wrap a plain SPD array in DenseOperator, default-construct the
inner solver from a bare string, bundle the outer-loop knobs into an
:class:~nncg.solver.ActiveSetConfig — and delegate to
:class:~nncg.solver.ActiveSetSolver. They hold no logic of their own: reach
past them to ActiveSetSolver directly whenever you need to reuse a
configured solver across problems, or an inner solver the string shortcut cannot
express (inner=Nystrom(nystrom=NystromConfig(rank=20)) still works here,
passed as an instance). :func:solve_nnqp_mprgp is the matching one-call wrapper
over the projection-based :class:~nncg.mprgp.MPRGP solver for the same
bound-constrained problem.
InnerKind = Literal['cg', 'jacobi', 'nystrom', 'global_nystrom', 'exact']
module-attribute
¶
The bare-string shortcuts accepted for inner (keys of :data:_INNER).
solve_nnqp(a, b, *, inner='cg', warm=None, tol=1e-08, p_max=3, track=False, max_outer=None)
¶
Minimise 1/2 x^T A x - b^T x over x >= 0 — the one-call entry point.
A thin convenience wrapper that composes the three pieces of the layered API
for the common case: it wraps a plain SPD array in DenseOperator, default-
constructs the inner solver from a bare string, and bundles the outer-loop
knobs into an :class:ActiveSetConfig, then delegates to
:meth:ActiveSetSolver.solve. It holds no logic of its own — reach past it to
:class:ActiveSetSolver directly whenever you need to reuse a configured
solver across problems, or an inner solver this shortcut cannot express.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
a
|
SymmetricOperator | NDArray[float64]
|
The SPD quadratic term. A :class: |
required |
b
|
Vector
|
The linear term |
required |
inner
|
InnerSolver | InnerKind
|
The inner solver for each free block, as an
:class: |
'cg'
|
warm
|
tuple[NDArray[bool_], Vector] | None
|
Optional |
None
|
tol
|
float
|
Threshold of the primal and dual KKT violator tests
( |
1e-08
|
p_max
|
int
|
Patience budget before a least-index Bland fallback pivot
( |
3
|
track
|
bool
|
Record the visited free-set trajectory in |
False
|
max_outer
|
int | None
|
Optional cap on outer steps; when hit, the current iterate is
returned with |
None
|
Returns:
| Name | Type | Description |
|---|---|---|
A |
Result
|
class: |
Result
|
to |
Raises:
| Type | Description |
|---|---|
TypeError
|
When |
ValueError
|
When |
Examples:
The bound binds where the unconstrained minimiser would go negative. Here
A^-1 b = [1, -1], so the second coordinate is clamped to zero:
>>> import numpy as np
>>> a = np.array([[2.0, 0.0], [0.0, 2.0]])
>>> b = np.array([2.0, -2.0])
>>> result = solve_nnqp(a, b)
>>> result.converged
True
>>> result.x.round(6).tolist()
[1.0, 0.0]
Source code in src/nncg/api.py
solve_nnqp_eq(a, b, b_eq, c_eq, *, inner='cg', warm=None, tol=1e-08, p_max=3, track=False, max_outer=None)
¶
Solve min 1/2 x^T A x - b^T x s.t. x >= 0 and B x = c — one call.
The equality-augmented companion to :func:solve_nnqp, with identical
wrapping and configuration conventions; it delegates to
:meth:ActiveSetSolver.solve_eq, where the per-free-set saddle system and the
full-row-rank requirement on B are documented. The single normalisation
1^T x = beta is the p = 1 case.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
a
|
SymmetricOperator | NDArray[float64]
|
The SPD quadratic term — a :class: |
required |
b
|
Vector
|
The linear term |
required |
b_eq
|
Matrix
|
Equality matrix |
required |
c_eq
|
Vector
|
Equality right-hand side |
required |
inner
|
InnerSolver | InnerKind
|
The inner solver instance, or a shortcut string — see
:func: |
'cg'
|
warm
|
tuple[NDArray[bool_], Vector] | None
|
Optional |
None
|
tol
|
float
|
KKT violator tolerance ( |
1e-08
|
p_max
|
int
|
Bland-fallback patience budget ( |
3
|
track
|
bool
|
Record the visited free-set trajectory in |
False
|
max_outer
|
int | None
|
Optional outer-step cap; |
None
|
Returns:
| Name | Type | Description |
|---|---|---|
A |
Result
|
class: |
Result
|
gradient underlying the dual test is |
Raises:
| Type | Description |
|---|---|
TypeError
|
When |
ValueError
|
When |
Examples:
The p = 1 normalisation 1^T x = 1 — the minimum-norm point on the
simplex, here its centre:
>>> import numpy as np
>>> a = np.eye(2) * 2.0
>>> b = np.zeros(2)
>>> result = solve_nnqp_eq(a, b, np.array([[1.0, 1.0]]), np.array([1.0]))
>>> result.converged
True
>>> result.x.round(6).tolist()
[0.5, 0.5]
Source code in src/nncg/api.py
solve_nnqp_mprgp(a, b, *, x0=None, tol=1e-08, gamma=1.0, alpha_bar=None, max_iter=100000, seed=0)
¶
Minimise 1/2 x^T A x - b^T x over x >= 0 by MPRGP — one call.
The projection-based companion to :func:solve_nnqp: it solves the same
bound-constrained program with Dostál & Schöberl's MPRGP
(:class:nncg.mprgp.MPRGP) instead of the active-set loop — matrix-free and
factorisation-free, so it never forms or refactorises A. Like
:func:solve_nnqp it wraps a plain SPD array in DenseOperator and bundles
the knobs into an :class:nncg.mprgp.MPRGPConfig, then delegates to
:meth:nncg.mprgp.MPRGP.solve. The equality-augmented variant is not covered
— use :func:solve_nnqp_eq for B x = c.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
a
|
SymmetricOperator | NDArray[float64]
|
The SPD quadratic term. A :class: |
required |
b
|
Vector
|
The linear term |
required |
x0
|
Vector | None
|
Optional feasible warm start, projected onto |
None
|
tol
|
float
|
Relative projected-gradient stopping tolerance
( |
1e-08
|
gamma
|
float
|
Proportioning constant |
1.0
|
alpha_bar
|
float | None
|
Fixed projected-gradient step in |
None
|
max_iter
|
int
|
Iteration cap; |
100000
|
seed
|
int
|
Seed of the power-iteration |
0
|
Returns:
| Name | Type | Description |
|---|---|---|
An |
MPRGPResult
|
class: |
MPRGPResult
|
projected gradient fell below |
|
MPRGPResult
|
global minimiser. |
Raises:
| Type | Description |
|---|---|
TypeError
|
When |
ValueError
|
When the operator dimension does not match |
Examples:
The same program as the :func:solve_nnqp example, reached by projection
instead of the active-set loop — same unique minimiser:
>>> import numpy as np
>>> a = np.array([[2.0, 0.0], [0.0, 2.0]])
>>> b = np.array([2.0, -2.0])
>>> result = solve_nnqp_mprgp(a, b)
>>> result.converged
True
>>> result.x.round(6).tolist()
[1.0, 0.0]
Source code in src/nncg/api.py
Active-set solver¶
The primal-dual active-set loop with the unconditional finite-termination
guarantee — this package's subject. solve_eq adds the equality-augmented
Bx = c variant via a p-by-p Schur complement.
nncg.solver
¶
Non-negative conjugate gradients: the active-set / block-principal-pivoting loop.
Solves the strictly convex non-negative quadratic program
min_{x >= 0} 1/2 x^T A x - b^T x, A symmetric positive definite,
and its equality-augmented variant with a general linear system B x = c,
by wrapping a matrix-free inner solver in a primal-dual active-set outer loop.
The working-set toggles are the principal pivots of the linear complementarity
problem LCP(A, -b); guarding the fast block-pivot path with a least-index Bland
fallback gives unconditional finite termination at the unique global minimiser
— no non-degeneracy assumption (Theorem 5.1 of the accompanying paper). See
https://github.com/Jebel-Quant/mean_variance_solvers.
:class:ActiveSetSolver is the outer loop and the entry point. It knows nothing
about preconditioning: it asks its :class:nncg.inner.InnerSolver for a
per-free-block solve and drives the pivots around it. The quadratic term enters
as a :class:cvx.linalg.SymmetricOperator, accessed only through block products
— wrap an explicit SPD array in DenseOperator, or pass GramOperator(M,
ridge) for A = M^T M + ridge I so the n x n matrix is never formed.
ActiveSetConfig
dataclass
¶
Configuration of the active-set outer loop (:class:ActiveSetSolver).
Bundles the outer-loop knobs into one argument; the inner solver and its
tolerances live in :class:nncg.inner.InnerSolver, and the warm start stays
a separate argument.
Attributes:
| Name | Type | Description |
|---|---|---|
tol |
float
|
Threshold of the primal and dual KKT violator tests. |
p_max |
int
|
Patience budget — non-improving batch steps tolerated before a least-index Bland fallback pivot. Any value gives finite termination. |
track |
bool
|
Record the visited free-set trajectory in |
max_outer |
int | None
|
Optional cap on outer steps; when hit, the current iterate is
returned with |
Source code in src/nncg/solver.py
ActiveSetSolver
dataclass
¶
The primal-dual active-set outer loop for the non-negative quadratic program.
Holds the outer-loop :class:ActiveSetConfig and an
:class:nncg.inner.InnerSolver, and drives the guarded block-pivot loop
around the per-free-block solve the inner solver provides. It never touches a
preconditioner — everything about CG/PCG/Nyström lives in inner.
Attributes:
| Name | Type | Description |
|---|---|---|
inner |
InnerSolver
|
The inner solver for each free block — e.g. :class: |
config |
ActiveSetConfig
|
Outer-loop configuration (violator tolerance, patience, trajectory tracking, outer-step cap). |
Examples:
A enters as an operator, never as a bare array:
>>> import numpy as np
>>> from cvx.linalg import DenseOperator
>>> from nncg import ActiveSetSolver, CG, kkt_violation
>>> a = DenseOperator(np.array([[2.0, 0.0], [0.0, 2.0]]))
>>> b = np.array([2.0, -2.0])
The unconstrained minimiser would be (1, -1), so the bound binds on
the second coordinate and the loop returns (1, 0) with that
coordinate active:
>>> res = ActiveSetSolver(inner=CG()).solve(a, b)
>>> res.converged
True
>>> bool(np.allclose(res.x, [1.0, 0.0]))
True
>>> res.free.tolist()
[True, False]
converged is the KKT exit, which :func:nncg.kkt_violation scores
independently — zero certifies the unique global minimiser:
Generic data never needs the Bland fallback; that it stayed dormant is reported rather than assumed:
Swap the inner solver freely — the outer loop is unchanged, and on this problem so is the answer:
>>> from nncg import Exact
>>> direct = ActiveSetSolver(inner=Exact()).solve(a, b)
>>> bool(np.allclose(direct.x, res.x))
True
Source code in src/nncg/solver.py
112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 | |
solve(a, b, warm=None)
¶
Minimise 1/2 x^T A x - b^T x over x >= 0 by the active-set loop.
Each free-block solve is delegated to :attr:inner; the reduced matrix
is never materialised and A is never refactorised. The batch
block-pivot fast path is guarded by a least-index Bland fallback, so
termination at the unique global minimiser is unconditional.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
a
|
SymmetricOperator
|
The SPD operator |
required |
b
|
Vector
|
The linear term |
required |
warm
|
tuple[NDArray[bool_], Vector] | None
|
Optional |
None
|
Returns:
| Name | Type | Description |
|---|---|---|
A |
Result
|
class: |
Result
|
satisfied to |
|
Result
|
minimiser. |
Raises:
| Type | Description |
|---|---|
TypeError
|
When |
ValueError
|
When the operator dimension does not match |
NotImplementedError
|
When a diagonal-preconditioned inner solver
(:class: |
Source code in src/nncg/solver.py
solve_eq(a, b, b_eq, c_eq, warm=None)
¶
Solve min 1/2 x^T A x - b^T x subject to x >= 0 and B x = c.
On each free set the saddle system is solved by eliminating the
multiplier lambda in R^p through the p-by-p Schur complement
S = B_F A_F^{-1} B_F^T: the p + 1 right-hand sides share the
operator A_F and are each one inner solve, then S lambda = c - B_F
v0 fixes the multipliers in closed form. The single normalisation
1^T x = beta is the p = 1 case. B must have full row rank on
the visited free sets (automatic for p = 1).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
a
|
SymmetricOperator
|
The SPD operator |
required |
b
|
Vector
|
The linear term |
required |
b_eq
|
Matrix
|
Equality matrix |
required |
c_eq
|
Vector
|
Equality right-hand side |
required |
warm
|
tuple[NDArray[bool_], Vector] | None
|
Optional |
None
|
Returns:
| Name | Type | Description |
|---|---|---|
A |
Result
|
class: |
Result
|
gradient underlying the dual test is |
Raises:
| Type | Description |
|---|---|
TypeError
|
When |
ValueError
|
When the operator dimension does not match |
NotImplementedError
|
When a diagonal-preconditioned inner solver
(:class: |
Source code in src/nncg/solver.py
InnerSolver
¶
Bases: Protocol
The inner-solver interface the active-set loop depends on (dependency inversion).
Structural (a :class:typing.Protocol): anything with a matching
:meth:solve is an inner solver, so implementations need neither import
nor subclass this — this module (the high-level loop) owns the interface, and
the implementations depend on it, not the other way round. The built-ins live
in :mod:nncg.inner (:class:~nncg.inner.CG, :class:~nncg.inner.Jacobi,
:class:~nncg.inner.Nystrom, :class:~nncg.inner.Exact); further ones —
Clarabel- or KKT-equation-based — live in Jebel-Quant/mean_variance_solvers.
Source code in src/nncg/solver.py
solve(op, idx, rhs, x0)
¶
Solve the free-block system A[F, F] y = rhs, warm-started at x0.
Returns the free-block solution and the inner iteration count (each
direct solve counts as one). Called once per outer step by the
bound-constrained loop, and once per p + 1 right-hand side per outer
step by the equality-augmented loop.
Source code in src/nncg/solver.py
Result
dataclass
¶
Outcome of an active-set solve.
Attributes:
| Name | Type | Description |
|---|---|---|
x |
Vector
|
The minimiser (or the final iterate if |
outer |
int
|
Number of outer active-set steps taken. |
inner |
int
|
Total inner (CG/PCG) iterations across all outer steps; each direct inner solve counts as one. |
fallback |
int
|
Number of least-index Bland fallback pivots taken. |
converged |
bool
|
True when the KKT exit was reached; False when an
|
free |
NDArray[bool_]
|
Boolean mask of the final free set. |
lam |
Vector | None
|
Multipliers of the equality constraints (equality-augmented solves only; None otherwise). |
traj |
list[tuple[int, ...]] | None
|
The sequence of visited free sets as index tuples when trajectory tracking was requested; None otherwise. |
Source code in src/nncg/solver.py
MPRGP¶
A standalone matrix-free projection solver for the same bound-constrained problem (Dostál & Schöberl) — conjugate-gradient, expansion and proportioning steps under the proportioning test, no factorisation. A first-order alternative to the active-set loop; bound constraints only.
nncg.mprgp
¶
MPRGP: modified proportioning with reduced gradient projections.
A matrix-free, projection-based alternative outer solver for the same strictly convex non-negative quadratic program the active-set loop targets,
min_{x >= 0} 1/2 x^T A x - b^T x, A symmetric positive definite.
Where :class:nncg.solver.ActiveSetSolver toggles a working set and solves an
unconstrained system on each free block, MPRGP (Dostál & Schöberl, 2005) never
factorises anything: it interleaves three cheap first-order moves, each costing
one or two Hessian products,
- a conjugate-gradient step that minimises within the current face while it stays feasible (the free set unchanged),
- an expansion step that walks to the nearest bound and takes one fixed-step projected-gradient move to add constraints to the active set, and
- a proportioning step along the chopped gradient that removes constraints from the active set,
switched by the proportioning test ||beta(x)||^2 <= gamma^2 phi~(x)^T phi(x).
With the projected-gradient step bounded by alpha_bar in (0, 2/||A||] the
iteration converges for any feasible start, and — because it identifies the
active set of the minimiser in finitely many steps and then reduces to plain CG
on the optimal face — it terminates finitely in exact arithmetic. A enters
only through :meth:cvx.linalg.SymmetricOperator.matvec, so the n x n matrix
is never formed; ||A|| for the step bound is estimated matrix-free by power
iteration.
This is the bound-constrained solver; the equality-augmented variant B x = c
is out of scope here (it needs an augmented-Lagrangian outer wrap, SMALBE/SMALSE
around MPRGP) — use :meth:nncg.solver.ActiveSetSolver.solve_eq for that.
Reference: Z. Dostál and J. Schöberl, "Minimizing quadratic functions subject to bound constraints with the rate of convergence and finite termination", Comput. Optim. Appl. 30 (2005), 23-43.
Iterate = tuple[Vector, Vector, Vector]
module-attribute
¶
The MPRGP iteration state (x, g, p): iterate, gradient A x - b, CG direction.
Every move consumes one state and returns the next, so the loop in :func:_mprgp
carries no other mutable numerics — only the counters the result reports.
MatVec = Callable[[Vector], Vector]
module-attribute
¶
The action v -> A v of the SPD operator — MPRGP's only access to A.
MPRGP
dataclass
¶
The MPRGP solver for the non-negative quadratic program.
A matrix-free, factorisation-free alternative to
:class:nncg.solver.ActiveSetSolver on the bound-constrained problem
min_{x>=0} 1/2 x^T A x - b^T x: it interleaves conjugate-gradient,
expansion and proportioning steps under the proportioning test, never forming
or factorising A. Holds only its :class:MPRGPConfig; the operator and
right-hand side are passed to :meth:solve.
Attributes:
| Name | Type | Description |
|---|---|---|
config |
MPRGPConfig
|
Solver configuration (tolerance, proportioning constant, projected-gradient step, iteration cap, seed). |
Examples:
The same operator interface as the active-set loop:
>>> import numpy as np
>>> from cvx.linalg import DenseOperator
>>> from nncg import MPRGP, MPRGPConfig, kkt_violation
>>> a = DenseOperator(np.array([[2.0, 0.0], [0.0, 2.0]]))
>>> b = np.array([2.0, -2.0])
>>> res = MPRGP().solve(a, b)
>>> res.converged
True
>>> bool(np.allclose(res.x, [1.0, 0.0]))
True
>>> round(kkt_violation(a, b, res.x), 12)
0.0
The counts break the run down by move, and always sum to
iterations; hessian_products is the honest matrix-free cost —
at least one product per step, plus the initial gradient:
>>> res.iterations == res.cg_steps + res.expansion_steps + res.proportioning_steps
True
>>> res.hessian_products > res.iterations
True
Nothing is factorised, so the whole configuration is a handful of
scalars — here a tighter tolerance and an explicit projected-gradient
step, which skips the power-iteration estimate of 1/||A||:
>>> tight = MPRGP(config=MPRGPConfig(tol=1e-12, alpha_bar=0.5)).solve(a, b)
>>> tight.converged
True
>>> bool(np.allclose(tight.x, [1.0, 0.0]))
True
Source code in src/nncg/mprgp.py
319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 | |
solve(a, b, x0=None)
¶
Minimise 1/2 x^T A x - b^T x over x >= 0 by MPRGP.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
a
|
SymmetricOperator
|
The SPD operator |
required |
b
|
Vector
|
The linear term |
required |
x0
|
Vector | None
|
Optional feasible warm start; it is projected onto |
None
|
Returns:
| Name | Type | Description |
|---|---|---|
An |
MPRGPResult
|
class: |
MPRGPResult
|
gradient fell below |
|
MPRGPResult
|
global minimiser. |
Raises:
| Type | Description |
|---|---|
TypeError
|
When |
ValueError
|
When the operator dimension does not match |
Source code in src/nncg/mprgp.py
MPRGPConfig
dataclass
¶
Configuration of the MPRGP solver (:class:MPRGP).
Attributes:
| Name | Type | Description |
|---|---|---|
tol |
float
|
Relative stopping tolerance on the projected gradient — the loop
exits when |
gamma |
float
|
Proportioning constant |
alpha_bar |
float | None
|
Fixed projected-gradient step, which must satisfy
|
max_iter |
int
|
Iteration cap; the current iterate is returned with
|
seed |
int
|
Seed of the power-iteration |
Raises:
| Type | Description |
|---|---|
ValueError
|
When |
Source code in src/nncg/mprgp.py
__post_init__()
¶
Validate that the proportioning constant is strictly positive.
MPRGPResult
dataclass
¶
Outcome of an MPRGP solve.
The iteration counts are broken out by move because they carry the algorithm's
signature: expansion and proportioning steps are the ones that change the
active set, while a run of conjugate-gradient steps is plain CG on a fixed
face. hessian_products is the honest cost of a matrix-free method — one
product per CG or proportioning step, two per expansion step, plus one for the
initial gradient.
Attributes:
| Name | Type | Description |
|---|---|---|
x |
Vector
|
The minimiser (or the final iterate if |
iterations |
int
|
Total MPRGP steps taken (the sum of the three move counts). |
hessian_products |
int
|
Number of operator matrix-vector products consumed. |
cg_steps |
int
|
Conjugate-gradient (minimisation-within-the-face) steps. |
expansion_steps |
int
|
Expansion (bound-hitting projected-gradient) steps. |
proportioning_steps |
int
|
Proportioning (constraint-releasing) steps. |
converged |
bool
|
True when the projected-gradient stopping test was met; False
when |
free |
NDArray[bool_]
|
Boolean mask of the final free set ( |
Source code in src/nncg/mprgp.py
KKT certificate¶
nncg.certificate
¶
The KKT certificate for the non-negative quadratic program and its shared precondition.
:func:kkt_violation scores how far a candidate is from the unique global
minimiser of min_{x>=0} 1/2 x'Ax - b'x — zero certifies optimality — and is
the load-bearing check the paper's numerical study reports against.
:func:_require_operator is the one operator/right-hand-side precondition shared
by the certificate and both :class:nncg.solver.ActiveSetSolver entry points.
kkt_violation(a, b, x)
¶
Maximum violation of the KKT system of min_{x>=0} 1/2 x'Ax - b'x.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
a
|
SymmetricOperator
|
The SPD operator |
required |
b
|
Vector
|
The linear term |
required |
x
|
Vector
|
Candidate solution. |
required |
Returns:
| Type | Description |
|---|---|
float
|
|
float
|
gradient |
float
|
|
Examples:
Note that a must be an operator — a bare array raises TypeError:
>>> import numpy as np
>>> from cvx.linalg import DenseOperator
>>> a = DenseOperator(np.array([[2.0, 0.0], [0.0, 2.0]]))
>>> b = np.array([2.0, -2.0])
The minimiser certifies at zero, while the origin does not:
>>> round(kkt_violation(a, b, np.array([1.0, 0.0])), 12)
0.0
>>> kkt_violation(a, b, np.zeros(2)) > 0
True
Source code in src/nncg/certificate.py
Inner solvers¶
The pluggable free-block solvers the active-set loop delegates to. Pass an
instance to ActiveSetSolver(inner=...) to tune one; the string shortcuts on
the wrappers take defaults only.
nncg.inner
¶
Inner solvers: one free-block system A[F, F] y = rhs per active-set step.
Each concrete inner solver provides solve(op, idx, rhs, x0) -> (y, iters),
solving the free-block system A[F, F] y = rhs (and so satisfies the
:class:nncg.solver.InnerSolver interface). This is the only module that knows
about preconditioning: the built-in solvers are the identity/Jacobi/Nyström-
preconditioned CG variants (:class:CG, :class:Jacobi, :class:Nystrom,
:class:GlobalNystrom) and the direct :class:Exact. The operator-derived
builders they run on — the free-block matvec and the diagonal/Nyström
preconditioners — live in :mod:nncg.preconditioners. Further inner solvers —
e.g. Clarabel- or KKT-equation-based — live in Jebel-Quant/mean_variance_solvers
and satisfy the same structural interface.
Examples:
The inner solver is the one thing that varies between these runs — the outer loop, and the minimiser it certifies, are the same:
>>> import numpy as np
>>> from cvx.linalg import DenseOperator
>>> from nncg import CG, ActiveSetSolver, Exact, Jacobi
>>> a = DenseOperator(np.diag([1.0, 2.0, 4.0, 8.0]))
>>> b = np.array([1.0, 2.0, -4.0, -8.0])
>>> for inner in (CG(), Jacobi(), Exact()):
... res = ActiveSetSolver(inner=inner).solve(a, b)
... print(type(inner).__name__, res.converged, np.allclose(res.x, [1.0, 1.0, 0.0, 0.0]))
CG True True
Jacobi True True
Exact True True
The direct solver counts one inner "iteration" per solve, so it never spends more than the number of outer steps:
The Nyström solvers sketch the free block at :attr:NystromConfig.rank, so
they belong on a problem larger than that rank and with a decaying spectrum
— here three orders of geometric decay, with a planted optimum on the even
coordinates:
>>> from nncg import GlobalNystrom, Nystrom
>>> d = 10.0 ** -np.linspace(0.0, 3.0, 40)
>>> x_star = np.where(np.arange(40) % 2 == 0, 1.0, 0.0)
>>> b = d * x_star - (1.0 - x_star)
>>> for inner in (Nystrom(), GlobalNystrom()):
... res = ActiveSetSolver(inner=inner).solve(DenseOperator(np.diag(d)), b)
... print(type(inner).__name__, res.converged, np.allclose(res.x, x_star))
Nystrom True True
GlobalNystrom True True
CG
dataclass
¶
Plain matrix-free conjugate gradients (the identity preconditioner).
Attributes:
| Name | Type | Description |
|---|---|---|
krylov |
KrylovConfig
|
Tolerance and iteration cap of the CG solves ( |
Source code in src/nncg/inner.py
solve(op, idx, rhs, x0)
¶
Solve the free block A[F, F] y = rhs by plain CG.
Exact
dataclass
¶
Direct free-block solve via op.solve_free (one "iteration" per solve).
Suits backends whose solve_free is structured and cheap (e.g.
FactorOperator's Woodbury solve at O(|F| r^2)). It ignores warm starts.
The rcond_free conditioning guard depends only on the free block, not the
right-hand side, so it is estimated at most once per free set:
:meth:nncg.solver.ActiveSetSolver.solve_eq drives p + 1 solves through
the same free set per outer step, and the (up to O(|F|^3)) estimate must
not be paid p + 1 times over. The last verified (operator, idx) is
memoised in a private single slot — keyed on operator identity so the memo can
never carry a stale verdict across operators, and excluded from equality/repr
so Exact stays a value.
On the plain :meth:~nncg.solver.ActiveSetSolver.solve path every outer step
visits a different free set, so the memo never hits and the guard is paid on
every step — where, for a dense free block, the O(|F|^3) eigendecomposition
can cost several times the Cholesky solve it precedes. The guard is also
redundant when solve_free already fails loudly on a rank-deficient block
(e.g. cvx.linalg.cholesky_solve's Cholesky→LU fallback). Set
check_conditioning=False to skip it and let solve_free surface any
singularity itself.
Attributes:
| Name | Type | Description |
|---|---|---|
check_conditioning |
bool
|
Estimate |
Source code in src/nncg/inner.py
solve(op, idx, rhs, x0)
¶
Solve the free block A[F, F] y = rhs directly, guarding its conditioning once per free set.
Source code in src/nncg/inner.py
GlobalNystrom
dataclass
¶
Nyström-preconditioned CG sketched once on the full operator, then masked per free block.
:class:Nystrom resketches A[F, F] from scratch on every outer step —
the rank + oversample matrix-free products, a QR, a small Cholesky and an
SVD, all paid again each time the free set changes. This class instead
sketches the full operator A once: restricting a rank-rank
factorization to a principal submatrix is exact
((U diag(lam) U^T)[F, F] = U_F diag(lam) U_F^T for U_F = U[F, :]),
so masking rows of the one global basis gives a valid free-block
preconditioner with no further matrix-free products against A — only a
small rank x rank factorization per free block (see
:func:nncg.preconditioners._masked_nystrom). This amortises well when the
same operator is solved repeatedly (a parameter sweep, successive warm
starts) or the active-set loop takes many outer steps; the trade is a
preconditioner not adapted to each free block's own local spectrum, so it
can take a few more CG iterations than a freshly-sketched :class:Nystrom
on a small or spectrally unusual free block.
The global sketch is memoised in a private single slot, keyed on operator
identity so the cache can never carry a stale sketch across operators
(mirrors :class:Exact's conditioning memo) — excluded from equality/repr
so this class stays a value.
Attributes:
| Name | Type | Description |
|---|---|---|
krylov |
KrylovConfig
|
Tolerance and iteration cap of the CG solves ( |
nystrom |
NystromConfig
|
Sketch rank, oversampling, shift and seed of the global sketch
(see :class: |
Source code in src/nncg/inner.py
solve(op, idx, rhs, x0)
¶
Solve the free block A[F, F] y = rhs by CG preconditioned from the masked global sketch.
Source code in src/nncg/inner.py
Jacobi
dataclass
¶
Jacobi-preconditioned CG — runs at the operator's condition number, a bad diagonal scaling removed.
Attributes:
| Name | Type | Description |
|---|---|---|
krylov |
KrylovConfig
|
Tolerance and iteration cap of the CG solves ( |
Source code in src/nncg/inner.py
solve(op, idx, rhs, x0)
¶
Solve the free block A[F, F] y = rhs by Jacobi-preconditioned CG.
Source code in src/nncg/inner.py
Nystrom
dataclass
¶
Randomized Nyström-preconditioned CG — for free blocks with a steeply decaying spectrum.
Attributes:
| Name | Type | Description |
|---|---|---|
krylov |
KrylovConfig
|
Tolerance and iteration cap of the CG solves ( |
nystrom |
NystromConfig
|
Sketch rank, oversampling, shift and seed of the low-rank
preconditioner (see :class: |
Source code in src/nncg/inner.py
solve(op, idx, rhs, x0)
¶
Solve the free block A[F, F] y = rhs by Nyström-preconditioned CG (plain CG on an empty block).
Source code in src/nncg/inner.py
NystromConfig
dataclass
¶
Tuning knobs for the Nyström preconditioner of :class:nncg.inner.Nystrom.
Attributes:
| Name | Type | Description |
|---|---|---|
rank |
int
|
Target sketch rank — the number of leading eigenpairs captured, clamped to the free-block dimension. |
oversample |
int
|
Extra sketch columns drawn for accuracy before truncating
back to |
shift |
float | None
|
Explicit scalar tail eigenvalue, or |
seed |
int | None
|
Seed for the Gaussian test matrix; fixed by default so a solve is
reproducible. |
Raises:
| Type | Description |
|---|---|
ValueError
|
When |
Source code in src/nncg/preconditioners.py
__post_init__()
¶
Validate that the sketch rank is a positive integer.
Krylov core¶
The in-house matrix-free CG and Jacobi-preconditioned CG, warm-startable. This is the package's core contribution — the inner solvers drive it rather than you calling it directly.
nncg.krylov
¶
Matrix-free (preconditioned) conjugate gradients — the Krylov core.
:func:pcg solves an SPD system accessed only through a mat-vec callable — the
matrix is never required explicitly — and takes its preconditioner as a callable
r -> M^{-1} r (config.precond=None recovers plain CG). It is
operator-agnostic: the preconditioner builders that turn a
:class:cvx.linalg.SymmetricOperator into such a callable live in
:mod:nncg.inner, alongside the inner solvers that use them. Convergence is
governed by the spectral condition number of M^{-1} A at the
O(sqrt(kappa)) Krylov rate.
KrylovConfig
dataclass
¶
Options for a preconditioned CG solve (:func:pcg).
Bundles the solve knobs into one argument so :func:pcg keeps a short
signature (matrix and right-hand side, then the config).
Attributes:
| Name | Type | Description |
|---|---|---|
precond |
Preconditioner | None
|
The action |
tol |
float
|
Relative residual stopping tolerance |
maxit |
int
|
Iteration cap; the current iterate is returned when it is hit. |
x0 |
Vector | None
|
Optional warm start. The initial residual is |
Source code in src/nncg/krylov.py
pcg(matvec, rhs, config=_DEFAULT_KRYLOV)
¶
Solve an SPD system by preconditioned conjugate gradients.
PCG converges at the condition number of M^{-1} A rather than of A,
where the preconditioner M^{-1} (config.precond) enters only as the
action r -> M^{-1} r; config.precond=None is the identity, so PCG
reduces to plain CG. The inner solvers in :mod:nncg.inner build suitable
preconditioners from an operator (diagonal Jacobi, randomized Nyström).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
matvec
|
MatVec
|
The action |
required |
rhs
|
Vector
|
Right-hand side |
required |
config
|
KrylovConfig
|
Preconditioner, tolerance, iteration cap and warm start of the
solve (see :class: |
_DEFAULT_KRYLOV
|
Returns:
| Type | Description |
|---|---|
tuple[Vector, int]
|
The approximate solution and the number of iterations taken. |