Coverage for src/cvx/linalg/kkt/projection.py: 100%
27 statements
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-03 18:56 +0000
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-03 18:56 +0000
1"""Euclidean projection onto an affine set ``{x : C x = d}``."""
3from __future__ import annotations
5import numpy as np
7from ..core.exceptions import DimensionMismatchError, NotAMatrixError
8from ..core.types import Matrix, Vector
11class AffineProjection:
12 """Euclidean projection onto the affine set ``{x : C x = d}``.
14 The projection of ``x`` is the nearest point (in the 2-norm) satisfying the
15 equality constraints,
17 ``P(x) = x - C.T @ (C C.T)^{-1} @ (C x - d)``.
19 The Gram matrix ``C C.T`` is formed once at construction (the ``O(mc**2 * n)``
20 cost) and reused, so repeated projections -- for instance an alternating
21 box / affine projection loop -- pay only an ``mc x mc`` solve each. ``C`` should
22 have full row rank.
24 Args:
25 c: Constraint matrix ``C`` of shape ``(mc, n)``.
26 d: Constraint target ``d`` of shape ``(mc,)``.
28 Example:
29 >>> import numpy as np
30 >>> from cvx.linalg import AffineProjection
31 >>> proj = AffineProjection(np.ones((1, 3)), np.array([1.0]))
32 >>> p = proj.project(np.array([1.0, 1.0, 1.0]))
33 >>> bool(np.isclose(np.ones(3) @ p, 1.0)) # lands on the affine set
34 True
35 >>> np.allclose(p, [1 / 3, 1 / 3, 1 / 3]) # nearest point with sum 1
36 True
37 """
39 def __init__(self, c: Matrix, d: Vector) -> None:
40 """Store ``C`` and ``d`` and precompute the Gram matrix ``C C.T``."""
41 c = np.asarray(c, dtype=np.float64)
42 d = np.asarray(d, dtype=np.float64)
43 if c.ndim != 2:
44 raise NotAMatrixError(c.ndim, func="AffineProjection")
45 if d.ndim != 1 or d.shape[0] != c.shape[0]:
46 raise DimensionMismatchError(d.shape[0] if d.ndim == 1 else d.size, c.shape[0])
47 self._c = c
48 self._d = d
49 self._gram = c @ c.T
51 @property
52 def m(self) -> int:
53 """Number of constraints (rows of ``C``)."""
54 return int(self._c.shape[0])
56 @property
57 def n(self) -> int:
58 """Ambient dimension (columns of ``C``)."""
59 return int(self._c.shape[1])
61 def project(self, x: Vector | Matrix) -> Vector | Matrix:
62 """Return the Euclidean projection of ``x`` onto ``{x : C x = d}``.
64 Args:
65 x: Point of shape ``(n,)`` or a stack of points ``(n, k)``.
67 Returns:
68 The projected point(s), same shape as ``x``.
69 """
70 x = np.asarray(x, dtype=np.float64)
71 target = self._d if x.ndim == 1 else self._d[:, None]
72 residual = self._c @ x - target
73 correction = self._c.T @ np.linalg.solve(self._gram, residual)
74 return x - correction