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

57 statements  

« prev     ^ index     » next       coverage.py v7.15.3, created at 2026-08-06 11:28 +0000

1"""Orthogonal updates of a QR factorisation held as an explicit pair (Q, R). 

2 

3The Goldfarb/Idnani algorithm maintains a factorisation of the matrix of active 

4constraint normals. Every iteration either appends a column (a constraint enters 

5the active set) or drops one (a constraint leaves), so the factorisation is 

6updated rather than recomputed from scratch. 

7 

8``R`` is stored as packed columns, as in the reference C implementation: the 

9entry in column ``j`` and row ``i`` (both 0-based, ``i <= j``) lives at flat 

10offset ``j * (j + 1) // 2 + i``, so column ``j`` occupies one contiguous run of 

11``j + 1`` values. 

12 

13That layout is not just about the factor of two in memory. The solver's hot 

14operation is a triangular solve against the *leading* ``nact`` columns, and in 

15this form that submatrix is the leading ``nact * (nact + 1) // 2`` entries -- 

16contiguous, so BLAS ``tpsv`` reads it in place. The same leading block of a dense 

17``(r, r)`` array is strided, which forces a full copy on every call: at ``n = 

18700`` that measured 77 us per solve against 7.5 us for ``tpsv``, and the solve 

19runs once per iteration. 

20 

21The price is paid in :func:`qr_delete`, which mixes two *rows* across a range of 

22columns. Column strides grow with the column index, so that is a gather rather 

23than a slice -- see the index arithmetic there. 

24 

25Choice of transformation 

26------------------------ 

27The reference reduces an incoming column with a chain of Givens rotations, one 

28per trailing component. :func:`qr_insert` instead applies a single Householder 

29reflection, which is what the same reduction costs in one pair of BLAS calls 

30rather than ``n - r`` Python-level ones. 

31 

32This is not merely an equivalent-cost rearrangement, and it is worth being 

33precise about why it is legitimate. The two reductions produce *different* ``Q`` 

34and ``R``: signs along the diagonal of ``R``, and hence the signs of some columns 

35of ``Q``, differ. The solver is nonetheless unaffected, because the quantities it 

36actually consumes are invariant to that choice. With ``A`` the active normals and 

37``G^-1 = J J^T``, 

38 

39 R^T R = A^T J J^T A = A^T G^-1 A and R^T d_1 = A^T G^-1 n 

40 

41so the dual step direction 

42 

43 rv = R^-1 d_1 = (R^T R)^-1 R^T d_1 = (A^T G^-1 A)^-1 A^T G^-1 n 

44 

45is a function of ``A``, ``n`` and ``G`` alone. Replacing ``R`` by ``S R`` for any 

46sign matrix ``S`` also replaces ``d_1`` by ``S d_1``, and the two cancel exactly 

47(a sign flip is exact in IEEE arithmetic). The primal direction 

48``zv = J_2 d_2`` is invariant for the same reason: flipping the sign of a column 

49of ``J`` flips the matching entry of ``d``, and their product is unchanged. 

50 

51:func:`qr_delete` keeps the Givens chase, which is inherently sequential: each 

52rotation's parameters depend on the previous one having been applied. 

53""" 

54 

55# Q and R are the names this factorisation has in every reference on the subject, 

56# and the ones the reference implementation uses. Lowercasing them to satisfy 

57# pep8-naming would obscure that. The exemption lives here rather than in a 

58# [lint.per-file-ignores] block because ruff.toml is template-owned and a local 

59# edit to it is reverted by the next `/rhiza:update` sync. 

60# ruff: noqa: N803 

61 

62import math 

63from collections.abc import Callable 

64from typing import Any, cast 

65 

66import numpy as np 

67import scipy.linalg 

68 

69__all__ = ["qr_delete", "qr_insert"] 

70 

71# Rank-1 update, resolved once. Called directly rather than through np.outer so 

72# that the update lands in Q's own buffer instead of an O(n * k) temporary. The 

73# cast records that asking for a single name yields a single function, which 

74# scipy-stubs cannot express -- see the matching note in _solve.py. 

75_GER = cast("Callable[..., Any]", scipy.linalg.get_blas_funcs("ger", (np.empty(0, dtype=np.float64),))) 

76 

77 

78def qr_insert(r: int, av: np.ndarray, Q: np.ndarray, R: np.ndarray) -> None: 

79 """Append ``av`` to ``R`` as its ``r``-th column, keeping ``R`` triangular. 

80 

81 An orthogonal transformation is applied to ``av`` to annihilate the 

82 components beyond the ``r``-th, and the same transformation is applied to the 

83 columns of ``Q``. 

84 

85 ``R`` is upper triangular of order ``r - 1`` on entry and of order ``r`` on 

86 exit. All three arrays are modified in place. 

87 

88 Args: 

89 r: 1-based size of the active set *after* the insertion. 

90 av: Length-``n`` vector to append. Overwritten. 

91 Q: ``(n, n)`` array whose columns receive the transformation. Should be 

92 Fortran-ordered so that the column block is contiguous. 

93 R: packed upper triangular array receiving the new column. 

94 """ 

95 # Only columns r-1 .. n-1 take part. Columns of R already in place are 

96 # untouched: their entries at those positions are exact zeros, and any 

97 # combination of exact zeros is an exact zero. 

98 av[r - 1] = _reflect(av[r - 1 :], Q[:, r - 1 :]) 

99 

100 # Column r-1 holds rows 0 .. r-1, so it is r values at offset (r-1)r/2. 

101 start = (r - 1) * r // 2 

102 R[start : start + r] = av[:r] 

103 

104 

105def _reflect(v: np.ndarray, block: np.ndarray) -> float: 

106 """Reduce ``v`` to a multiple of ``e_1``, transforming ``block`` to match. 

107 

108 Applies the Householder reflection ``W = I - 2 u u^T / u^T u`` that maps ``v`` 

109 onto ``alpha e_1``, updating ``block`` in place as ``block @ W``. The sign of 

110 ``alpha`` follows ``v[0]``, matching the reference implementation's Givens 

111 chain, and its leading entry is formed by a cancellation-free rearrangement 

112 of ``v[0] - alpha``. 

113 

114 Args: 

115 v: Vector to reduce. Not modified. 

116 block: ``(n, len(v))`` column block to which the reflection is applied. 

117 Modified in place. 

118 

119 Returns: 

120 ``alpha``, the single surviving component of ``v``, carrying its sign. 

121 """ 

122 head = float(v[0]) 

123 tail = v[1:] 

124 tail_sq = float(tail @ tail) 

125 

126 if tail_sq == 0.0: 

127 # Already a multiple of e_1, so the reflection is the identity. This also 

128 # covers the len(v) == 1 case, where there is nothing to annihilate. 

129 return head 

130 

131 norm = math.sqrt(head * head + tail_sq) 

132 sign = 1.0 if head >= 0.0 else -1.0 

133 alpha = sign * norm 

134 

135 # u = v - alpha e_1. Writing the leading entry as 

136 # head - sign*norm = sign*(head^2 - norm^2)/(|head| + norm) 

137 # avoids the cancellation that the direct subtraction suffers when v is 

138 # already close to a positive multiple of e_1. 

139 u = v.copy() 

140 u[0] = -sign * tail_sq / (abs(head) + norm) 

141 beta = tail_sq + u[0] * u[0] 

142 

143 # block @ (I - 2 u u^T / beta), as a matrix-vector product and a rank-1 

144 # update -- two BLAS calls, independent of len(v). 

145 w = block @ u 

146 if block.flags.f_contiguous: 

147 # dger writes through to block's buffer, which is a view into Q. 

148 _GER(-2.0 / beta, w, u, a=block, overwrite_a=True) 

149 else: 

150 # A non-Fortran-ordered block would be copied by dger, losing the 

151 # update, so fall back to an explicit (allocating) rank-1 update. 

152 block -= np.outer(w, u * (2.0 / beta)) 

153 return alpha 

154 

155 

156def qr_delete(r: int, col: int, Q: np.ndarray, R: np.ndarray) -> None: 

157 """Drop the ``col``-th column of ``R``, restoring upper triangular form. 

158 

159 Orthogonal transformations are applied to the rows of ``R`` to bring it back 

160 to upper triangular form, and the same transformations are applied to the 

161 columns of ``Q``. 

162 

163 ``R`` is upper triangular of order ``r`` on entry and of order ``r - 1`` on 

164 exit. Entries outside the leading ``(r - 1, r - 1)`` block are left stale; 

165 the caller shrinks the active set accordingly, so they are never read before 

166 being overwritten by :func:`qr_insert`. 

167 

168 Args: 

169 r: 1-based size of the active set *before* the deletion. 

170 col: 1-based index of the column of ``R`` to drop. 

171 Q: ``(n, n)`` array whose columns receive the transformations. 

172 R: packed upper triangular array to be updated. 

173 """ 

174 for i in range(col, r): 

175 # On this iteration, reduce the (i, i) element of R to zero, 

176 # then move column i to position i - 1. 

177 diagonal = i * (i + 1) // 2 + i 

178 if R[diagonal] == 0.0: # pragma: no cover 

179 # Defensive, and unreachable in exact arithmetic: a diagonal entry 

180 # vanishes only if the active constraint normals lose independence, 

181 # which the solver's step rules prevent. Kept because the reference 

182 # has it, and cancellation could in principle produce a true zero. 

183 continue 

184 

185 # The transformation mixes rows i - 1 and i of R over columns i .. r - 1. 

186 # Consecutive rows of one column are adjacent, but the column offsets 

187 # grow, so addressing a row across columns needs explicit indices. 

188 columns = np.arange(i, r) 

189 lower = columns * (columns + 1) // 2 + i 

190 upper = lower - 1 

191 

192 if R[diagonal - 1] == 0.0: 

193 # Nothing to combine, so the reflection degenerates to a swap. 

194 # Fancy indexing reads copies, so the two assignments cannot alias. 

195 R[upper], R[lower] = R[lower], R[upper] 

196 _swap(Q[:, i - 1], Q[:, i]) 

197 else: 

198 gc, gs = _reflection_2x2(R[diagonal - 1], R[diagonal]) 

199 # Rows of R and columns of Q take the same 2x2 reflection: it is 

200 # symmetric, so transforming J's columns by it transforms J^T's rows 

201 # by it too, which is what keeps J^T A == [[R], [0]] intact. 

202 first, second = R[upper], R[lower] 

203 R[upper] = gc * first + gs * second 

204 R[lower] = gs * first - gc * second 

205 _mix(Q[:, i - 1], Q[:, i], gc, gs) 

206 

207 # Move column i left into slot i - 1, keeping its rows 0 .. i-1. The 

208 # entry just zeroed is dropped with the slot it vacates. 

209 R[(i - 1) * i // 2 : (i - 1) * i // 2 + i] = R[diagonal - i : diagonal] 

210 

211 

212def _reflection_2x2(x: float, y: float) -> tuple[float, float]: 

213 """Return the reflection ``[[c, s], [s, -c]]`` that annihilates ``y``. 

214 

215 The result is symmetric and orthogonal, so it is its own inverse. The sign of 

216 the hypotenuse follows ``x``, so the surviving component keeps its sign. 

217 

218 Args: 

219 x: Component to be preserved. 

220 y: Component to be annihilated. Must be nonzero. 

221 

222 Returns: 

223 The cosine and sine of the reflection. 

224 """ 

225 h = math.hypot(x, y) 

226 if x < 0.0: 

227 h = -h 

228 return x / h, y / h 

229 

230 

231def _mix(first: np.ndarray, second: np.ndarray, gc: float, gs: float) -> None: 

232 """Apply ``[[gc, gs], [gs, -gc]]`` to a pair of vectors in place. 

233 

234 Args: 

235 first: First vector, overwritten with ``gc * first + gs * second``. 

236 second: Second vector, overwritten with ``gs * first - gc * second``. 

237 gc: Cosine of the reflection. 

238 gs: Sine of the reflection. 

239 """ 

240 combined = gc * first + gs * second 

241 second *= -gc 

242 second += gs * first 

243 first[:] = combined 

244 

245 

246def _swap(first: np.ndarray, second: np.ndarray) -> None: 

247 """Exchange the contents of two vectors in place. 

248 

249 Args: 

250 first: First vector. 

251 second: Second vector. 

252 """ 

253 tmp = first.copy() 

254 first[:] = second 

255 second[:] = tmp