Coverage for src/cvx/quadprog/_pdas.py: 100%

108 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-09 18:50 +0000

1r"""A primal-dual active-set fast path, checked against the KKT conditions. 

2 

3The dual method in :mod:`._solve` reaches the active set one constraint at a 

4time, so its iteration count grows with the size of that set: a 

5budget-plus-bounds problem at ``n = 100`` takes 74 outer iterations, and at these 

6sizes a solve costs very nearly its count of interpreter-level operations rather 

7than its flops. 

8 

9This module guesses the whole active set instead, solves one dense KKT system for 

10it, and repairs the guess from the signs that come back -- a multiplier wanting to 

11go negative does not belong in the set, a violated constraint does. That converges 

12in two to four repairs across every family measured, independent of ``n``, so it 

13trades flops, which are free at these sizes, for dispatches, which are not. 

14 

15The trade is only sound because the answer is *checked*. Primal-dual active set is 

16not globally convergent: it can cycle, it can stabilise on a set whose KKT system 

17is singular, and -- measured, not hypothesised -- it can stabilise on a point that 

18is simply not the minimiser. Every candidate therefore goes through 

19:func:`_certified` before it is returned, and anything that fails is discarded so 

20:func:`~cvx.quadprog.solve_qp` can fall back to the exact walk. For a strictly 

21convex program the KKT conditions are sufficient, so a point that passes is the 

22unique minimiser and needs no second opinion. 

23""" 

24 

25# G and C are the names used in Goldfarb & Idnani (1983) and in the reference 

26# implementation's public signature, and CA, Y are the matrices of the KKT system 

27# as it is conventionally written. Lowercasing them here would break the 

28# correspondence with _solve.py, which waives the same rules for the same reason. 

29# ruff: noqa: N803, N806 

30 

31from typing import NamedTuple 

32 

33import numpy as np 

34import scipy.linalg as sla 

35 

36from ._base import Solution 

37 

38# Below this many variables the fast path is not attempted. Its guess is likeliest 

39# to be linearly dependent when there are few variables to spread the active set 

40# over, and that is also where it wins least: measured over 300 instances per 

41# size, the certified fraction is 100% from twelve variables up on every family 

42# tried, against 23% at n = 3 and 48% at n = 5 on equality-constrained problems, 

43# where the exact walk costs under 100 us anyway. 

44_MIN_VARIABLES = 12 

45 

46# Cap on set repairs before the attempt is abandoned. Two to four is the observed 

47# range; anything near this bound is not converging and is better handed over. 

48_MAX_REPAIRS = 30 

49 

50# Relative tolerance for deciding set membership. This one is not delicate: it 

51# steers which set is tried next, and a bad choice costs a repair or a fallback, 

52# never a wrong answer -- that is what the certificate is for. 

53_SET_TOL = 1e-10 

54 

55# Relative tolerance of the certificate itself, which *is* delicate, since it is 

56# the only thing standing between a non-optimal point and the caller. Measured 

57# over 1164 converged attempts, points that satisfied the conditions did so with a 

58# residual of at most 4e-15, while the two that did not missed by 1e-1 and worse. 

59# Anything between those extremes separates them; this sits six orders above the 

60# worst good residual and eight below the best bad one. 

61_CERTIFY_TOL = 1e-9 

62 

63 

64class Attempt(NamedTuple): 

65 """A candidate solution that has already passed the KKT certificate. 

66 

67 Attributes: 

68 x: ``(n,)`` minimiser. 

69 xu: ``(n,)`` unconstrained minimiser, ``G^-1 a``. 

70 lagrangian: ``(m,)`` multipliers, zero off the active set. 

71 active: ``(m,)`` boolean mask of the active constraints. 

72 added: Constraints added to the working set, summed over all repairs. 

73 dropped: Constraints removed from it, summed over all repairs. 

74 """ 

75 

76 x: np.ndarray 

77 xu: np.ndarray 

78 lagrangian: np.ndarray 

79 active: np.ndarray 

80 added: int 

81 dropped: int 

82 

83 

84def attempt(G: np.ndarray, a: np.ndarray, C: np.ndarray, b: np.ndarray, meq: int) -> Attempt | None: 

85 """Try to solve by primal-dual active set, returning None if anything is off. 

86 

87 Every rejection path -- too small, singular, cycling, not converging, or 

88 failing the certificate -- returns None rather than raising, because the 

89 caller's response to all of them is the same: solve it the exact way. 

90 

91 Args: 

92 G: ``(n, n)`` symmetric positive definite matrix of the quadratic term. 

93 a: ``(n,)`` vector of the linear term. 

94 C: ``(n, m)`` constraint matrix, one column per constraint. 

95 b: ``(m,)`` right-hand side of ``C.T @ x >= b``. 

96 meq: Number of leading constraints held as equalities. 

97 

98 Returns: 

99 A certified :class:`Attempt`, or None if the fast path did not produce 

100 one. 

101 """ 

102 seeded = _seed(G, a, C, b, meq) 

103 if seeded is None: 

104 return None 

105 cho, xu, active, scale = seeded 

106 m = C.shape[1] 

107 

108 seen: set[bytes] = set() 

109 added, dropped = int(active.sum()), 0 

110 least_index = False 

111 steps, limit = 0, _MAX_REPAIRS 

112 while steps < limit: 

113 steps += 1 

114 step = _working_set_solve(cho, xu, C, b, active, m) 

115 if step is None: 

116 return None 

117 x, lagrangian = step 

118 

119 slack = C.T @ x - b 

120 following = _repair(active, lagrangian, slack, meq, _SET_TOL * scale, least_index) 

121 

122 if np.array_equal(following, active): 

123 if not _certified(G, a, C, b, meq, x, lagrangian): 

124 return None 

125 return Attempt(x, xu, lagrangian, active, added, dropped) 

126 

127 key = following.tobytes() 

128 if key in seen: 

129 if least_index: 

130 return None 

131 # The block exchange is going round in circles. Drop to one index at 

132 # a time, lowest first, and give it room to walk there. 

133 least_index = True 

134 limit = steps + m 

135 following = _repair(active, lagrangian, slack, meq, _SET_TOL * scale, True) 

136 key = following.tobytes() 

137 # No second guard here: flipping one index always changes the set, and 

138 # if that set has been seen before the check at the top catches it on 

139 # the next pass, by which time `least_index` is set and it gives up. 

140 

141 seen.add(key) 

142 added += int((following & ~active).sum()) 

143 dropped += int((~following & active).sum()) 

144 active = following 

145 

146 return None 

147 

148 

149def _seed( 

150 G: np.ndarray, a: np.ndarray, C: np.ndarray, b: np.ndarray, meq: int 

151) -> tuple[tuple[np.ndarray, bool], np.ndarray, np.ndarray, float] | None: 

152 """Factorise ``G`` and pick the working set to start from, or decline. 

153 

154 The guess is the equalities plus whatever the unconstrained minimiser 

155 violates, which is already the answer when it violates nothing. 

156 

157 Args: 

158 G: ``(n, n)`` symmetric positive definite matrix of the quadratic term. 

159 a: ``(n,)`` vector of the linear term. 

160 C: ``(n, m)`` constraint matrix. 

161 b: ``(m,)`` right-hand side. 

162 meq: Number of leading constraints held as equalities. 

163 

164 Returns: 

165 The Cholesky factorisation, the unconstrained minimiser, the starting 

166 working set and the scale the tolerances are measured against; or None if 

167 the problem is one the fast path does not take. 

168 """ 

169 n, m = C.shape 

170 if n < _MIN_VARIABLES or m == 0 or meq > n: 

171 return None 

172 

173 try: 

174 cho = sla.cho_factor(G) 

175 xu = sla.cho_solve(cho, a) 

176 except (np.linalg.LinAlgError, ValueError): 

177 return None 

178 

179 scale = max(1.0, float(np.abs(b).max(initial=0.0))) 

180 active = np.zeros(m, dtype=bool) 

181 active[:meq] = True 

182 active |= C.T @ xu < b - _SET_TOL * scale 

183 return cho, xu, active, scale 

184 

185 

186def _repair( 

187 active: np.ndarray, 

188 lagrangian: np.ndarray, 

189 slack: np.ndarray, 

190 meq: int, 

191 tol: float, 

192 least_index: bool, 

193) -> np.ndarray: 

194 """Return the working set to try next. 

195 

196 The block rule exchanges every index that violates its sign condition at 

197 once, which is what converges in two to four repairs when it converges at 

198 all. Exchanging a batch can also over-shoot -- a drop can remove support that 

199 a later add restores, returning to a set already visited -- and that is what 

200 the least-index rule is for: flip only the lowest-indexed offender, the 

201 anti-cycling device of Bland and of Murty's least-index rule for 

202 complementarity problems. It is slower per step and it is not a termination 

203 proof here, since the general constraints leave no P-matrix to appeal to (see 

204 :func:`_working_set_solve`), but it makes progress where the block rule 

205 merely oscillates. 

206 

207 Args: 

208 active: Current working set. 

209 lagrangian: Multipliers at the current point. 

210 slack: ``C.T @ x - b`` at the current point. 

211 meq: Number of leading constraints held as equalities. 

212 tol: Absolute tolerance for a sign being meant. 

213 least_index: Whether to exchange one index rather than all of them. 

214 

215 Returns: 

216 The next working set. 

217 """ 

218 following = active.copy() 

219 following[meq:] = ((lagrangian[meq:] > -tol) & active[meq:]) | (slack[meq:] < -tol) 

220 if not least_index: 

221 return following 

222 

223 offenders = np.flatnonzero(following != active) 

224 following = active.copy() 

225 if offenders.size: 

226 first = int(offenders[0]) 

227 following[first] = not following[first] 

228 return following 

229 

230 

231def _working_set_solve( 

232 cho: tuple[np.ndarray, bool], 

233 xu: np.ndarray, 

234 C: np.ndarray, 

235 b: np.ndarray, 

236 active: np.ndarray, 

237 m: int, 

238) -> tuple[np.ndarray, np.ndarray] | None: 

239 """Minimise with the working set held as equalities. 

240 

241 Stationarity gives ``x = xu + G^-1 C_A nu``, and substituting it into 

242 ``C_A^T x = b_A`` leaves ``(C_A^T G^-1 C_A) nu = b_A - C_A^T xu``. That matrix 

243 is positive definite exactly when ``C_A`` has full column rank, so its 

244 Cholesky doubles as the rank test: a guess that made the working set linearly 

245 dependent fails here instead of returning nonsense. 

246 

247 Args: 

248 cho: Cholesky factorisation of ``G``, from ``scipy.linalg.cho_factor``. 

249 xu: Unconstrained minimiser. 

250 C: Constraint matrix. 

251 b: Right-hand side. 

252 active: Boolean mask of the working set. 

253 m: Total number of constraints. 

254 

255 Returns: 

256 The minimiser and the full multiplier vector, or None if the working set 

257 was rank deficient. 

258 """ 

259 lagrangian = np.zeros(m) 

260 idx = np.flatnonzero(active) 

261 if idx.size == 0: 

262 return xu, lagrangian 

263 

264 CA = C[:, idx] 

265 Y = sla.cho_solve(cho, CA) 

266 try: 

267 nu = sla.cho_solve(sla.cho_factor(CA.T @ Y), b[idx] - CA.T @ xu) 

268 except (np.linalg.LinAlgError, ValueError): 

269 return None 

270 

271 lagrangian[idx] = nu 

272 return xu + Y @ nu, lagrangian 

273 

274 

275def _certified( 

276 G: np.ndarray, 

277 a: np.ndarray, 

278 C: np.ndarray, 

279 b: np.ndarray, 

280 meq: int, 

281 x: np.ndarray, 

282 lagrangian: np.ndarray, 

283) -> bool: 

284 """Return whether the KKT conditions hold, which for this problem is proof. 

285 

286 The program is strictly convex, so these conditions are sufficient and not 

287 merely necessary: a point satisfying them is *the* minimiser. Stationarity is 

288 checked against ``G`` directly rather than trusted from the construction, 

289 since the construction is exactly what an ill-conditioned working set 

290 corrupts. 

291 

292 Args: 

293 G: Matrix of the quadratic term. 

294 a: Vector of the linear term. 

295 C: Constraint matrix. 

296 b: Right-hand side. 

297 meq: Number of leading constraints held as equalities. 

298 x: Candidate minimiser. 

299 lagrangian: Candidate multipliers. 

300 

301 Returns: 

302 True when every condition holds to :data:`_CERTIFY_TOL`. 

303 """ 

304 scale = max( 

305 1.0, 

306 float(np.abs(a).max(initial=0.0)), 

307 float(np.abs(b).max(initial=0.0)), 

308 float(np.abs(lagrangian).max(initial=0.0)), 

309 ) 

310 tol = _CERTIFY_TOL * scale 

311 slack = C.T @ x - b 

312 return bool( 

313 np.all(np.abs(G @ x - a - C @ lagrangian) <= tol) 

314 and np.all(np.abs(slack[:meq]) <= tol) 

315 and np.all(slack[meq:] >= -tol) 

316 and np.all(lagrangian[meq:] >= -tol) 

317 and np.all(np.abs(lagrangian[meq:] * slack[meq:]) <= tol) 

318 ) 

319 

320 

321def _fast_solution( 

322 G: np.ndarray, 

323 a: np.ndarray, 

324 C: np.ndarray, 

325 b: np.ndarray, 

326 meq: int, 

327 check_finite: bool, 

328) -> Solution | None: 

329 """Assemble a :class:`Solution` from a certified fast-path attempt. 

330 

331 Anything malformed returns None rather than raising, so that the message the 

332 caller sees for a bad problem is the one the exact path raises, unchanged. 

333 

334 Args: 

335 G: ``(n, n)`` matrix of the quadratic term. 

336 a: ``(n,)`` vector of the linear term. 

337 C: ``(n, m)`` constraint matrix. 

338 b: ``(m,)`` right-hand side. 

339 meq: Number of leading constraints held as equalities. 

340 check_finite: Whether to reject non-finite input. 

341 

342 Returns: 

343 The solution, or None if the fast path declined the problem. 

344 """ 

345 G = np.asarray(G, dtype=np.float64) 

346 a = np.asarray(a, dtype=np.float64) 

347 C = np.asarray(C, dtype=np.float64) 

348 b = np.asarray(b, dtype=np.float64) 

349 

350 if meq < 0 or not _shapes_agree(G, a, C, b): 

351 return None 

352 if check_finite and not all(bool(np.isfinite(array).all()) for array in (G, a, C, b)): 

353 return None 

354 

355 found = attempt(G, a, C, b, meq) 

356 if found is None: 

357 return None 

358 

359 return Solution( 

360 x=found.x, 

361 f=float(found.x @ G @ found.x) / 2.0 - float(a @ found.x), 

362 xu=found.xu, 

363 iterations=np.array([found.added, found.dropped], dtype=np.int64), 

364 lagrangian=found.lagrangian, 

365 iact=np.flatnonzero(found.active).astype(np.int64) + 1, 

366 ) 

367 

368 

369def _shapes_agree(G: np.ndarray, a: np.ndarray, C: np.ndarray, b: np.ndarray) -> bool: 

370 """Return whether the four arrays describe a well-formed program. 

371 

372 This is deliberately not the full validation :func:`_validate` performs. It 

373 only has to be strict enough that the fast path never works on nonsense; a 

374 problem it turns away is then rejected, with the proper message, by the exact 

375 path that follows. 

376 

377 Args: 

378 G: Matrix of the quadratic term. 

379 a: Vector of the linear term. 

380 C: Constraint matrix. 

381 b: Right-hand side. 

382 

383 Returns: 

384 True when the shapes are mutually consistent. 

385 """ 

386 return ( 

387 G.ndim == 2 

388 and G.shape[0] == G.shape[1] 

389 and a.shape == (G.shape[0],) 

390 and C.ndim == 2 

391 and C.shape[0] == G.shape[0] 

392 and b.shape == (C.shape[1],) 

393 )