Coverage for src/cvxball/solver.py: 100%

120 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-09-07 19:33 +0000

1"""Convex utilities for computing the minimum enclosing circle/ball. 

2 

3:func:`min_circle_active_set` runs an active-set QP method on the *dual* of the 

4enclosing-ball problem -- a QP over the unit simplex -- keeping a support set of 

5points on the ball's boundary. Each iteration costs one small dense linear 

6solve, and it terminates at an exact vertex of the dual feasible set rather than 

7at an interior-point tolerance. 

8 

9It is the default of the two solvers this package ships, being the faster on 

10every cloud measured and the one that returns a checkable dual; 

11:mod:`cvxball.fischer_gaertner_kutz` is the other, and reaches the same answer 

12from the primal-feasible side. NumPy carries the method; SciPy 

13carries the factorisation of its support at large `d`, through the compiled 

14Givens updates in :mod:`cvxball._frame` -- see :data:`_MAINTAIN_MIN_DIM` for 

15where that starts to pay and why it is not used below it. 

16The cone-program route that used to sit beside it -- assembling the 

17second-order-cone program by hand and handing it to Clarabel -- now lives in 

18``experiments/clarabel_ball.py``, because that is what it had become: the 

19reference this method is measured against rather than a second way to get an 

20answer. Moving it there is what lets Clarabel be a development dependency, so 

21installing this package pulls in NumPy, SciPy, and nothing else. 

22""" 

23 

24import numpy as np 

25 

26from cvxball._frame import _MaintainedFace 

27 

28 

29def _validate(points: np.ndarray) -> np.ndarray: 

30 """Check that ``points`` is a usable point cloud, and return it as floats. 

31 

32 Every rejection here is one a solver would otherwise take: a 1-D array used to 

33 fail when ``points.shape`` was unpacked into two names, and an empty or 

34 non-finite cloud used to reach the cone solver and come back as 

35 ``DualInfeasible`` or ``NumericalError`` -- a status describing the program 

36 rather than the input that produced it. Refusing the same cases with the 

37 reason keeps the caller's error about the caller's data. The Clarabel route in 

38 ``experiments/clarabel_ball.py`` imports this function for that reason. 

39 

40 Args: 

41 points: The candidate array of shape ``(n, d)``. 

42 

43 Returns: 

44 ``points`` as a float64 array, ready for the solver. 

45 

46 Raises: 

47 ValueError: If ``points`` is not two-dimensional, holds no points, has 

48 no coordinates, or contains a NaN or an infinity. 

49 """ 

50 array = np.asarray(points, dtype=np.float64) 

51 

52 if array.ndim != 2: 

53 raise ValueError(f"points must be a 2-D (n, d) array, got {array.ndim}-D of shape {array.shape}") # noqa: TRY003 

54 if array.shape[0] == 0: 

55 raise ValueError("points is empty: the smallest enclosing ball of no points is undefined") # noqa: TRY003 

56 if array.shape[1] == 0: 

57 raise ValueError("points has no coordinates: shape (n, 0) describes no ambient space") # noqa: TRY003 

58 if not np.isfinite(array).all(): 

59 bad = int(np.count_nonzero(~np.isfinite(array))) 

60 raise ValueError(f"points must be finite: found {bad} NaN or infinite coordinate(s)") # noqa: TRY003 

61 

62 return array 

63 

64 

65# --- Active-set (support-set) method ------------------------------------------ 

66# 

67# The enclosing-ball problem has the concave dual 

68# 

69# maximise sum_i u_i ||p_i||^2 - ||sum_i u_i p_i||^2 over u >= 0, sum_i u_i = 1, 

70# 

71# a quadratic program over the unit simplex whose optimal centre is x = sum_i u_i p_i 

72# and whose optimal value is the squared radius. Writing the convex negated dual as 

73# g(u) = ||x||^2 - sum_i u_i ||p_i||^2, its KKT conditions read 

74# 

75# ||p_i - x|| == R for every i with u_i > 0 (support points sit on the boundary) 

76# ||p_i - x|| <= R for every i with u_i == 0 (every other point is enclosed), 

77# 

78# which is precisely the geometric optimality certificate for the smallest enclosing 

79# ball. The routines below run a primal active-set QP method on that dual: the free 

80# set is the current support, every subproblem is a small dense linear system, and 

81# each iteration either adds the farthest violating point or drops a support point 

82# whose weight reaches zero. 

83 

84# Weights live on the unit simplex, so this is an absolute floor on a support weight. 

85_DROP_TOL = 1e-12 

86# Slack, relative to the squared radius, before a point counts as outside the ball. 

87_FEAS_RTOL = 1e-9 

88# Second, scale-aware slack, in multiples of the rounding error of a squared distance. 

89# That error tracks the squared extent of the centred cloud, so a constant absolute 

90# tolerance would carry units of length^2 and would silently declare optimality on 

91# small-magnitude inputs. 

92_FEAS_NOISE = 64.0 

93# Safety net only: the method is finite, so hitting this means numerical trouble. 

94_MAX_ITER_PER_POINT = 50 

95# Ambient dimension from which carrying the factorisation across iterations starts 

96# to pay. Below it the SciPy update calls cost more than the rebuild they save -- 

97# measured at 1000 standard normal points on an Apple M4 Pro, best of many runs: 

98# 

99# d rebuild maintained 

100# 20 0.79 ms 1.05 ms 0.75x -- maintaining loses 

101# 50 2.18 ms 2.52 ms 0.86x 

102# 100 4.78 ms 4.60 ms 1.04x -- parity, near enough 

103# 250 20.6 ms 16.2 ms 1.27x -- and it grows from here 

104# 8000 11.3 s 3.19 s 3.54x 

105# 

106# So this is an empirical constant, not a derived one, and it is a threshold on 

107# the *dimension* rather than on the support size because the choice has to be 

108# made once, before the support exists. `maintain=` overrides it either way. 

109_MAINTAIN_MIN_DIM = 100 

110 

111 

112def _sq_dist(points: np.ndarray, centre: np.ndarray) -> np.ndarray: 

113 """Compute squared distances from every row of ``points`` to ``centre``. 

114 

115 Differencing before squaring is what keeps this accurate. Expanding instead to 

116 ``||p||^2 - 2 p'x + ||x||^2`` costs a cancellation between terms of size 

117 ``||x||^2``, so for a cloud sitting far from the origin the digits that survive 

118 are exactly the ones the answer needs — the part of size ``R^2``. 

119 

120 Args: 

121 points: A ``(n, d)`` array of points. 

122 centre: A ``(d,)`` centre. 

123 

124 Returns: 

125 The ``(n,)`` array of squared distances. 

126 """ 

127 offsets = points - centre 

128 squared: np.ndarray = np.einsum("ij,ij->i", offsets, offsets) 

129 return squared 

130 

131 

132def _affine_null_space(face: np.ndarray) -> np.ndarray: 

133 """Find weight directions that reshuffle the support without moving the centre. 

134 

135 A weight update ``p`` leaves both the simplex constraint and the centre 

136 ``x = face.T @ u`` untouched exactly when ``sum(p) == 0`` and ``face.T @ p == 0``. 

137 Eliminating ``p_0 = -(p_1 + ... + p_{m-1})`` collapses that pair of conditions to 

138 the single condition ``edges.T @ p[1:] == 0`` on the edge matrix 

139 ``edges[j] = q_j - q_0``, so such directions exist exactly when the face is 

140 affinely *dependent*. Along one of them the quadratic term of ``g`` is frozen, so 

141 ``g`` is *linear* and the equality-constrained subproblem is unbounded — the active 

142 set then has to shrink instead of jumping to a minimiser. 

143 

144 Testing the rank on the edges rather than on ``[ones; face.T]`` is what keeps this 

145 scale-invariant. With the row of ones stacked on, its unit entries dominate the 

146 singular values, and every cloud whose extent falls below ``eps`` relative to 1 

147 would be misjudged affinely dependent. 

148 

149 Args: 

150 face: A ``(m, d)`` array of the points currently in the support set. 

151 

152 Returns: 

153 An ``(m, q)`` array whose columns span those directions. It has ``q == 0`` 

154 columns exactly when the points of ``face`` are affinely independent — the 

155 regular case, where the subproblem instead has the unique solution computed 

156 by :func:`_face_weights`. 

157 """ 

158 edges = face[1:] - face[0] 

159 if edges.shape[0] == 0: 

160 return np.zeros((1, 0)) 

161 

162 # Only the left factor is read. When ``edges`` is no taller than it is wide the 

163 # reduced decomposition already returns all ``m - 1`` of its columns -- a complete 

164 # orthonormal basis of the edge row space *and* its null space -- so asking for the 

165 # full one there buys nothing and costs the ``d x d`` right factor, built and then 

166 # discarded. That discarded factor dominates everything at large ``d``: 2 GB per 

167 # call at ``d = 16000``, and about forty times the cost of the whole solve. 

168 # 

169 # The guard is not decoration. Between a drop and the add that follows, the support 

170 # can reach ``d + 2`` points, and on such a face the reduced left factor is 

171 # ``(m - 1) x d`` -- one column short of spanning, and the column it is short of is 

172 # exactly the null direction this function exists to find. Reporting that face as 

173 # affinely independent would send the caller to :func:`_face_weights` with a 

174 # singular system. 

175 complete = edges.shape[0] > edges.shape[1] 

176 left, singular_values, _ = np.linalg.svd(edges, full_matrices=complete) 

177 cutoff = max(edges.shape) * float(np.finfo(np.float64).eps) * float(singular_values[0]) 

178 rank = int(np.count_nonzero(singular_values > cutoff)) 

179 

180 # Lift each edge-space null vector z back to a weight direction [-sum(z), z]. 

181 tail = left[:, rank:] 

182 return np.vstack([-tail.sum(axis=0, keepdims=True), tail]) 

183 

184 

185def _face_weights(face: np.ndarray) -> np.ndarray: 

186 """Solve the subproblem: put every point of one face on a common sphere. 

187 

188 The centre is written as ``x = q_0 + D y``, where ``D`` holds the edges 

189 ``q_j - q_0`` and so confines ``x`` to the affine hull of the face. Equating 

190 the distances from ``x`` to ``q_0`` and to each ``q_j`` then collapses to the 

191 tiny normal-equation system ``(D' D) y = h`` with ``h_j = ||q_j - q_0||^2 / 2``, 

192 which is non-singular precisely because the face is affinely independent. 

193 

194 Args: 

195 face: A ``(m, d)`` array of affinely independent points. 

196 

197 Returns: 

198 The ``(m,)`` weights, summing to one, that express the circumcentre as an 

199 affine combination of ``face``. A negative entry means the circumcentre 

200 lies outside the simplex, so the caller has to drop a support point 

201 instead of taking the full step. 

202 """ 

203 edges = face[1:] - face[0] 

204 if edges.shape[0] == 0: 

205 return np.ones(1) 

206 

207 gram = edges @ edges.T 

208 rhs = 0.5 * np.einsum("ij,ij->i", edges, edges) 

209 y = np.linalg.solve(gram, rhs) 

210 return np.concatenate(([1.0 - float(y.sum())], y)) 

211 

212 

213def _shrink(support: "_RebuiltFace | _MaintainedFace", keep: np.ndarray) -> None: 

214 """Drop every support position whose weight has fallen to zero. 

215 

216 Positions are removed back to front so the earlier ones keep their indices, 

217 which matters because a maintained factorisation is repaired per removal. 

218 

219 Args: 

220 support: The face to shrink, in place. 

221 keep: Boolean mask over the current support; ``False`` entries go. 

222 """ 

223 for position in sorted(np.flatnonzero(~keep).tolist(), reverse=True): 

224 support.remove(position) 

225 

226 

227class _RebuiltFace: 

228 """The support set, with its subproblem recomputed from the points each time. 

229 

230 The counterpart of :class:`cvxball._frame._MaintainedFace`, and the cheaper of 

231 the two whenever the support is small: there is no factorisation to carry, so 

232 nothing to repair, and the whole cost is two small dense decompositions that 

233 NumPy dispatches straight into LAPACK. Below :data:`_MAINTAIN_MIN_DIM` that 

234 beats paying SciPy's per-update overhead. 

235 """ 

236 

237 def __init__(self, points: np.ndarray, seed: int) -> None: 

238 """Start from the one-point support ``{seed}``. 

239 

240 Args: 

241 points: The ``(n, d)`` cloud, already centred and scaled. 

242 seed: Index of the point the support starts as. 

243 """ 

244 self._points = points 

245 self.support: list[int] = [seed] 

246 self.fallbacks = 0 

247 

248 @property 

249 def face(self) -> np.ndarray: 

250 """Return the ``(m, d)`` array of support points.""" 

251 return self._points[self.support] 

252 

253 def insert(self, index: int) -> None: 

254 """Take ``points[index]`` into the support. 

255 

256 Args: 

257 index: Index into the cloud of the entering point. 

258 """ 

259 self.support.append(index) 

260 

261 def remove(self, position: int) -> None: 

262 """Drop the support point at ``position``. 

263 

264 Args: 

265 position: Index within the support list. 

266 """ 

267 del self.support[position] 

268 

269 def null_space(self) -> np.ndarray: 

270 """Return the weight directions that leave the centre fixed.""" 

271 return _affine_null_space(self.face) 

272 

273 def circumcentre_weights(self) -> np.ndarray: 

274 """Return the barycentric weights of the face's circumcentre.""" 

275 return _face_weights(self.face) 

276 

277 

278def min_circle_active_set( 

279 points: np.ndarray, 

280 verbose: bool = False, 

281 maintain: bool | None = None, 

282) -> tuple[float, np.ndarray]: 

283 """Compute the smallest enclosing circle with an active-set method. 

284 

285 An active-set QP method on the dual of the enclosing-ball problem, in place of 

286 handing a cone program to a conic solver. It 

287 maintains a *support set* of points held on the ball's boundary and repeatedly 

288 

289 1. centres the ball on that support set by solving one small linear system 

290 (:func:`_face_weights`), 

291 2. shrinks the support when it cannot hold — either because a weight would turn 

292 negative, or because the set has become affinely dependent 

293 (:func:`_affine_null_space`) — moving as far as non-negativity allows, and 

294 3. adds the farthest point that is still outside the ball. 

295 

296 Each subproblem is a ``k x k`` solve with ``k <= d``, so the cost per iteration 

297 is driven by the dimension rather than by the number of points, and the method 

298 stops at an exact vertex of the dual feasible set instead of at an 

299 interior-point tolerance. 

300 

301 Args: 

302 points: A numpy array of shape ``(n, d)`` where *n* is the number of 

303 points and *d* is the ambient dimension. 

304 verbose: If ``True``, print the support size and radius per iteration. 

305 Defaults to ``False``. 

306 maintain: Whether to carry the support's factorisation across iterations 

307 and repair it, rather than rebuilding it each time. ``None``, 

308 the default, decides on the ambient dimension: repairing costs 

309 ``O(d r)`` against ``O(d r^2)`` to rebuild, which is decisive by 

310 ``d = 250`` and a net loss below ``d = 100`` where SciPy's 

311 per-update overhead exceeds what it saves (see 

312 :data:`_MAINTAIN_MIN_DIM`). Both settings compute the same ball; 

313 pass one explicitly only to measure the difference. 

314 

315 Returns: 

316 A tuple ``(radius, center)`` where *radius* is the optimal enclosing 

317 radius (float) and *center* is a numpy array of shape ``(d,)``. 

318 

319 Raises: 

320 ValueError: If ``points`` is not a finite, non-empty ``(n, d)`` array 

321 (see :func:`_validate`), or if the iteration limit is reached 

322 without the optimality certificate holding. 

323 

324 Example: 

325 Three points forming a right triangle, whose smallest enclosing circle is 

326 the one on its hypotenuse. Both values are pinned to full precision here, 

327 where the cone program in ``experiments/clarabel_ball.py`` can only pin its 

328 centre to three decimals — this method stops at an exact vertex of the 

329 dual feasible set, so on an input whose answer is exactly representable it 

330 returns that answer bit-for-bit. 

331 

332 >>> import numpy as np 

333 >>> from cvxball import min_circle_active_set 

334 >>> points = np.array([[0, 0], [1, 0], [0, 1]]) 

335 >>> radius, center = min_circle_active_set(points) 

336 >>> radius == 2**0.5 / 2 

337 True 

338 >>> center 

339 array([0.5, 0.5]) 

340 """ 

341 pts = _validate(points) 

342 n = pts.shape[0] 

343 

344 # Work in coordinates centred on the cloud, undoing the shift on the way out. 

345 # Every quantity below -- the subproblem's Gram matrix, the squared distances, the 

346 # rounding floor -- is then governed by the *extent* of the cloud rather than by 

347 # its distance from an arbitrary origin. Without this, a cloud of extent 1 sitting 

348 # a million units out gets a rounding floor of the same order as its own radius, 

349 # and the optimality test below then accepts a ball that is visibly too small. 

350 shift = pts.mean(axis=0) 

351 pts = pts - shift 

352 

353 # Recentring fixes the origin but not the magnitude, and this method squares 

354 # everything it touches: the Gram matrix, the squared distances, the noise floor. 

355 # Squaring halves the usable exponent range, so a cloud of extent 1e-160 -- whose 

356 # coordinates are ordinary doubles -- has a Gram matrix of order 1e-320, deep in 

357 # the subnormals, and the solve for the circumcentre then overflows to infinity. 

358 # The far end fails too, and worse: past 1e+154 the squares saturate and the 

359 # method returns a radius of zero rather than raising. 

360 # 

361 # So normalise the extent to order one. The factor is a *power of two*, which 

362 # makes the rescaling exact in binary floating point: not one bit of the answer 

363 # moves, so the method keeps returning representable answers bit-for-bit (see the 

364 # example above), and the whole iteration runs where doubles are well behaved. 

365 # Measuring the extent as a largest coordinate rather than a largest norm is what 

366 # keeps the measurement itself in range -- a norm would already have squared. 

367 largest = float(np.abs(pts).max()) 

368 if largest == 0.0: 

369 # Every point is the same point, so the ball is that point with radius zero. 

370 return 0.0, shift 

371 exponent = int(np.frexp(largest)[1]) 

372 pts = np.ldexp(pts, -exponent) 

373 

374 sq_norms: np.ndarray = np.einsum("ij,ij->i", pts, pts) 

375 

376 # Warm start with one point and whatever sits farthest from it: that puts both 

377 # ends of a near-diameter into the support straight away, which is usually 

378 # where the optimum keeps them. 

379 seed = int(np.argmax(_sq_dist(pts, pts[0]))) 

380 # Rounding floor of a squared distance, set by the magnitude of the coordinates 

381 # that go into it rather than by any fixed constant. 

382 noise_floor = _FEAS_NOISE * float(np.finfo(np.float64).eps) * float(sq_norms.max()) 

383 

384 if maintain is None: 

385 maintain = pts.shape[1] >= _MAINTAIN_MIN_DIM 

386 support = _MaintainedFace(pts, seed) if maintain else _RebuiltFace(pts, seed) 

387 free = support.support 

388 weights = np.ones(1) 

389 

390 iteration_limit = _MAX_ITER_PER_POINT * (n + 1) 

391 for iteration in range(iteration_limit): 

392 face = support.face 

393 null_space = support.null_space() 

394 

395 if null_space.size: 

396 # Affinely dependent support: g is linear on the null space, so follow 

397 # steepest descent there until some weight is driven down to zero. 

398 centre = face.T @ weights 

399 # grad_i = ||x||^2 - ||p_i - x||^2, less the constant ||x||^2: null 

400 # directions sum to zero, so dropping it leaves every projection alone 

401 # while avoiding the cancellation of ||x||^2 against ||p_i||^2. 

402 gradient = -_sq_dist(face, centre) 

403 descent = -(null_space @ (null_space.T @ gradient)) 

404 # Rescale to a unit-max direction. The gradient carries units of length^2, 

405 # so comparing it against the dimensionless weight tolerance below would 

406 # make the whole method scale-dependent: on a cloud of extent 1e-20 no 

407 # component would ever look binding. Only the direction matters here -- 

408 # `alpha` cancels any positive factor -- so normalising costs nothing. 

409 largest = float(np.abs(descent).max()) 

410 step = descent / largest if largest > 0.0 else descent 

411 else: 

412 target = support.circumcentre_weights() 

413 if target.min() >= -_DROP_TOL: 

414 # The subproblem solution is feasible: take it, then test the KKT 

415 # certificate and, if it fails, free the most violated point. 

416 weights = np.maximum(target, 0.0) 

417 weights /= weights.sum() 

418 centre = face.T @ weights 

419 dist_sq = _sq_dist(pts, centre) 

420 radius_sq = float(dist_sq[free].max()) 

421 radius = float(np.sqrt(max(radius_sq, 0.0))) 

422 if verbose: 

423 print(f"[{iteration:4d}] support={len(free):3d} radius={radius:.12g}") 

424 

425 worst = int(np.argmax(dist_sq)) 

426 if dist_sq[worst] <= radius_sq * (1.0 + _FEAS_RTOL) + noise_floor: 

427 # Undo the exact power-of-two normalisation, then the shift. 

428 return float(np.ldexp(radius, exponent)), np.ldexp(centre, exponent) + shift 

429 

430 keep = weights > _DROP_TOL 

431 _shrink(support, keep) 

432 support.insert(worst) 

433 weights = np.append(weights[keep], 0.0) 

434 continue 

435 

436 step = target - weights 

437 

438 # Longest step along `step` that keeps every weight non-negative. A step 

439 # of zero is impossible: the newly freed point always has step > 0, so the 

440 # support strictly shrinks here and `g` strictly decreases. 

441 binding = step < -_DROP_TOL 

442 ratios = np.where(binding, -weights / np.where(binding, step, -1.0), np.inf) 

443 alpha = float(ratios.min()) 

444 if not np.isfinite(alpha): 

445 # Nothing blocks the step, so the support cannot shrink. Unreachable in 

446 # exact arithmetic; bail out rather than propagate a non-finite weight. 

447 raise ValueError("active-set method stalled: no support point blocks the step") # noqa: TRY003 

448 weights = np.maximum(weights + alpha * step, 0.0) 

449 

450 keep = weights > _DROP_TOL 

451 _shrink(support, keep) 

452 weights = weights[keep] 

453 weights /= weights.sum() 

454 if verbose: 

455 print(f"[{iteration:4d}] support={len(free):3d} drop step={alpha:.6g}") 

456 

457 raise ValueError(f"active-set method did not converge in {iteration_limit} iterations") # noqa: TRY003