Coverage for src/nncg/inner.py: 100%

64 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-15 07:01 +0000

1"""Inner solvers: one free-block system ``A[F, F] y = rhs`` per active-set step. 

2 

3Each concrete inner solver provides ``solve(op, idx, rhs, x0) -> (y, iters)``, 

4solving the free-block system ``A[F, F] y = rhs`` (and so satisfies the 

5:class:`nncg.solver.InnerSolver` interface). This is the only module that knows 

6about preconditioning: the built-in solvers are the identity/Jacobi/Nyström- 

7preconditioned CG variants (:class:`CG`, :class:`Jacobi`, :class:`Nystrom`, 

8:class:`GlobalNystrom`) and the direct :class:`Exact`. The operator-derived 

9builders they run on — the free-block matvec and the diagonal/Nyström 

10preconditioners — live in :mod:`nncg.preconditioners`. Further inner solvers — 

11e.g. Clarabel- or KKT-equation-based — live in Jebel-Quant/mean_variance_solvers 

12and satisfy the same structural interface. 

13 

14Examples: 

15 The inner solver is the one thing that varies between these runs — the outer 

16 loop, and the minimiser it certifies, are the same: 

17 

18 >>> import numpy as np 

19 >>> from cvx.linalg import DenseOperator 

20 >>> from nncg import CG, ActiveSetSolver, Exact, Jacobi 

21 >>> a = DenseOperator(np.diag([1.0, 2.0, 4.0, 8.0])) 

22 >>> b = np.array([1.0, 2.0, -4.0, -8.0]) 

23 >>> for inner in (CG(), Jacobi(), Exact()): 

24 ... res = ActiveSetSolver(inner=inner).solve(a, b) 

25 ... print(type(inner).__name__, res.converged, np.allclose(res.x, [1.0, 1.0, 0.0, 0.0])) 

26 CG True True 

27 Jacobi True True 

28 Exact True True 

29 

30 The direct solver counts one inner "iteration" per solve, so it never spends 

31 more than the number of outer steps: 

32 

33 >>> direct = ActiveSetSolver(inner=Exact()).solve(a, b) 

34 >>> direct.inner <= direct.outer 

35 True 

36 

37 The Nyström solvers sketch the free block at :attr:`NystromConfig.rank`, so 

38 they belong on a problem larger than that rank and with a decaying spectrum 

39 — here three orders of geometric decay, with a planted optimum on the even 

40 coordinates: 

41 

42 >>> from nncg import GlobalNystrom, Nystrom 

43 >>> d = 10.0 ** -np.linspace(0.0, 3.0, 40) 

44 >>> x_star = np.where(np.arange(40) % 2 == 0, 1.0, 0.0) 

45 >>> b = d * x_star - (1.0 - x_star) 

46 >>> for inner in (Nystrom(), GlobalNystrom()): 

47 ... res = ActiveSetSolver(inner=inner).solve(DenseOperator(np.diag(d)), b) 

48 ... print(type(inner).__name__, res.converged, np.allclose(res.x, x_star)) 

49 Nystrom True True 

50 GlobalNystrom True True 

51""" 

52 

53from __future__ import annotations 

54 

55from dataclasses import dataclass, field, replace 

56 

57import numpy as np 

58from cvx.linalg import SymmetricOperator, Vector 

59from numpy.typing import NDArray 

60 

61from .krylov import KrylovConfig, Preconditioner, pcg 

62from .preconditioners import ( 

63 GlobalNystromSketch, 

64 NystromConfig, 

65 _free_matvec, 

66 _global_nystrom_sketch, 

67 _jacobi, 

68 _masked_nystrom, 

69 _nystrom, 

70) 

71 

72__all__ = ["CG", "Exact", "GlobalNystrom", "Jacobi", "Nystrom", "NystromConfig"] 

73 

74_RCOND_MIN = 1e-12 # matches cvx-linalg's DEFAULT_COND_THRESHOLD of 1e12 

75 

76 

77def _default_krylov() -> KrylovConfig: 

78 """Inner CG config with ``tol`` two orders below the outer tolerance (Lemma 5.1).""" 

79 return KrylovConfig(tol=1e-10) 

80 

81 

82def _pcg_block( 

83 op: SymmetricOperator, 

84 idx: NDArray[np.int_], 

85 rhs: Vector, 

86 x0: Vector | None, 

87 krylov: KrylovConfig, 

88 precond: Preconditioner | None, 

89) -> tuple[Vector, int]: 

90 """Solve the free block ``A[F, F] y = rhs`` by (preconditioned) CG, warm-started at ``x0``.""" 

91 return pcg(_free_matvec(op, idx), rhs, replace(krylov, precond=precond, x0=x0)) 

92 

93 

94def _same_free_block( 

95 checked_op: SymmetricOperator | None, 

96 checked_idx: NDArray[np.int_] | None, 

97 op: SymmetricOperator, 

98 idx: NDArray[np.int_], 

99) -> bool: 

100 """Return whether ``(op, idx)`` is the memoised free block already verified. 

101 

102 Keyed on operator *identity* (not equality) so the memo can never carry a 

103 stale verdict across operators; ``checked_idx is None`` means nothing has 

104 been verified yet. 

105 """ 

106 return checked_op is op and checked_idx is not None and np.array_equal(checked_idx, idx) 

107 

108 

109def _raise_if_singular(op: SymmetricOperator, idx: NDArray[np.int_]) -> None: 

110 """Raise if the free block ``A[F, F]`` is numerically singular (``rcond_free < _RCOND_MIN``).""" 

111 rcond = op.rcond_free(idx) 

112 if rcond < _RCOND_MIN: 

113 msg = f"free block of size {idx.size} is numerically singular (rcond={rcond:.2e})" 

114 raise ValueError(msg) 

115 

116 

117@dataclass(frozen=True) 

118class Exact: 

119 """Direct free-block solve via ``op.solve_free`` (one "iteration" per solve). 

120 

121 Suits backends whose ``solve_free`` is structured and cheap (e.g. 

122 ``FactorOperator``'s Woodbury solve at ``O(|F| r^2)``). It ignores warm starts. 

123 

124 The ``rcond_free`` conditioning guard depends only on the free block, not the 

125 right-hand side, so it is estimated at most once per free set: 

126 :meth:`nncg.solver.ActiveSetSolver.solve_eq` drives ``p + 1`` solves through 

127 the *same* free set per outer step, and the (up to ``O(|F|^3)``) estimate must 

128 not be paid ``p + 1`` times over. The last verified ``(operator, idx)`` is 

129 memoised in a private single slot — keyed on operator identity so the memo can 

130 never carry a stale verdict across operators, and excluded from equality/repr 

131 so ``Exact`` stays a value. 

132 

133 On the plain :meth:`~nncg.solver.ActiveSetSolver.solve` path every outer step 

134 visits a *different* free set, so the memo never hits and the guard is paid on 

135 every step — where, for a dense free block, the ``O(|F|^3)`` eigendecomposition 

136 can cost several times the Cholesky solve it precedes. The guard is also 

137 redundant when ``solve_free`` already fails loudly on a rank-deficient block 

138 (e.g. ``cvx.linalg.cholesky_solve``'s Cholesky→LU fallback). Set 

139 ``check_conditioning=False`` to skip it and let ``solve_free`` surface any 

140 singularity itself. 

141 

142 Attributes: 

143 check_conditioning: Estimate ``rcond_free`` and raise on a numerically 

144 singular free block before each (new) solve. Default ``True``; set 

145 ``False`` to trade the diagnostic for the raw structured solve. 

146 """ 

147 

148 check_conditioning: bool = True 

149 _checked_op: SymmetricOperator | None = field(default=None, compare=False, repr=False) 

150 _checked_idx: NDArray[np.int_] | None = field(default=None, compare=False, repr=False) 

151 

152 def solve(self, op: SymmetricOperator, idx: NDArray[np.int_], rhs: Vector, x0: Vector | None) -> tuple[Vector, int]: # noqa: ARG002 

153 """Solve the free block ``A[F, F] y = rhs`` directly, guarding its conditioning once per free set.""" 

154 if self.check_conditioning and not _same_free_block(self._checked_op, self._checked_idx, op, idx): 

155 _raise_if_singular(op, idx) 

156 object.__setattr__(self, "_checked_op", op) 

157 object.__setattr__(self, "_checked_idx", idx) 

158 return op.solve_free(idx, rhs), 1 

159 

160 

161@dataclass(frozen=True) 

162class CG: 

163 """Plain matrix-free conjugate gradients (the identity preconditioner). 

164 

165 Attributes: 

166 krylov: Tolerance and iteration cap of the CG solves (``tol`` defaults to ``1e-10``). 

167 """ 

168 

169 krylov: KrylovConfig = field(default_factory=_default_krylov) 

170 

171 def solve(self, op: SymmetricOperator, idx: NDArray[np.int_], rhs: Vector, x0: Vector | None) -> tuple[Vector, int]: 

172 """Solve the free block ``A[F, F] y = rhs`` by plain CG.""" 

173 return _pcg_block(op, idx, rhs, x0, self.krylov, None) 

174 

175 

176@dataclass(frozen=True) 

177class Jacobi: 

178 """Jacobi-preconditioned CG — runs at the operator's condition number, a bad diagonal scaling removed. 

179 

180 Attributes: 

181 krylov: Tolerance and iteration cap of the CG solves (``tol`` defaults to ``1e-10``). 

182 """ 

183 

184 krylov: KrylovConfig = field(default_factory=_default_krylov) 

185 

186 def solve(self, op: SymmetricOperator, idx: NDArray[np.int_], rhs: Vector, x0: Vector | None) -> tuple[Vector, int]: 

187 """Solve the free block ``A[F, F] y = rhs`` by Jacobi-preconditioned CG.""" 

188 return _pcg_block(op, idx, rhs, x0, self.krylov, _jacobi(op, idx)) 

189 

190 

191@dataclass(frozen=True) 

192class Nystrom: 

193 """Randomized Nyström-preconditioned CG — for free blocks with a steeply decaying spectrum. 

194 

195 Attributes: 

196 krylov: Tolerance and iteration cap of the CG solves (``tol`` defaults to ``1e-10``). 

197 nystrom: Sketch rank, oversampling, shift and seed of the low-rank 

198 preconditioner (see :class:`nncg.preconditioners.NystromConfig`). 

199 """ 

200 

201 krylov: KrylovConfig = field(default_factory=_default_krylov) 

202 nystrom: NystromConfig = field(default_factory=NystromConfig) 

203 

204 def solve(self, op: SymmetricOperator, idx: NDArray[np.int_], rhs: Vector, x0: Vector | None) -> tuple[Vector, int]: 

205 """Solve the free block ``A[F, F] y = rhs`` by Nyström-preconditioned CG (plain CG on an empty block).""" 

206 precond = _nystrom(op, idx, self.nystrom) if idx.size else None 

207 return _pcg_block(op, idx, rhs, x0, self.krylov, precond) 

208 

209 

210@dataclass(frozen=True) 

211class GlobalNystrom: 

212 """Nyström-preconditioned CG sketched once on the full operator, then masked per free block. 

213 

214 :class:`Nystrom` resketches ``A[F, F]`` from scratch on every outer step — 

215 the `rank + oversample` matrix-free products, a QR, a small Cholesky and an 

216 SVD, all paid again each time the free set changes. This class instead 

217 sketches the *full* operator ``A`` once: restricting a rank-``rank`` 

218 factorization to a principal submatrix is exact 

219 (``(U diag(lam) U^T)[F, F] = U_F diag(lam) U_F^T`` for ``U_F = U[F, :]``), 

220 so masking rows of the one global basis gives a valid free-block 

221 preconditioner with no further matrix-free products against ``A`` — only a 

222 small ``rank x rank`` factorization per free block (see 

223 :func:`nncg.preconditioners._masked_nystrom`). This amortises well when the 

224 same operator is solved repeatedly (a parameter sweep, successive warm 

225 starts) or the active-set loop takes many outer steps; the trade is a 

226 preconditioner not adapted to each free block's own local spectrum, so it 

227 can take a few more CG iterations than a freshly-sketched :class:`Nystrom` 

228 on a small or spectrally unusual free block. 

229 

230 The global sketch is memoised in a private single slot, keyed on operator 

231 *identity* so the cache can never carry a stale sketch across operators 

232 (mirrors :class:`Exact`'s conditioning memo) — excluded from equality/repr 

233 so this class stays a value. 

234 

235 Attributes: 

236 krylov: Tolerance and iteration cap of the CG solves (``tol`` defaults to ``1e-10``). 

237 nystrom: Sketch rank, oversampling, shift and seed of the global sketch 

238 (see :class:`nncg.preconditioners.NystromConfig`). 

239 """ 

240 

241 krylov: KrylovConfig = field(default_factory=_default_krylov) 

242 nystrom: NystromConfig = field(default_factory=NystromConfig) 

243 _checked_op: SymmetricOperator | None = field(default=None, compare=False, repr=False) 

244 _sketch: GlobalNystromSketch | None = field(default=None, compare=False, repr=False) 

245 

246 def _ensure_sketch(self, op: SymmetricOperator) -> GlobalNystromSketch: 

247 """Return the memoised global sketch of ``op``, (re)building it on a new operator.""" 

248 sketch = self._sketch 

249 if self._checked_op is not op or sketch is None: 

250 sketch = _global_nystrom_sketch(op, self.nystrom) 

251 object.__setattr__(self, "_sketch", sketch) 

252 object.__setattr__(self, "_checked_op", op) 

253 return sketch 

254 

255 def solve(self, op: SymmetricOperator, idx: NDArray[np.int_], rhs: Vector, x0: Vector | None) -> tuple[Vector, int]: 

256 """Solve the free block ``A[F, F] y = rhs`` by CG preconditioned from the masked global sketch.""" 

257 precond = _masked_nystrom(self._ensure_sketch(op), idx) if idx.size else None 

258 return _pcg_block(op, idx, rhs, x0, self.krylov, precond)