Coverage for src/cvx/linalg/kkt/projection.py: 100%
38 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-01 07:23 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-01 07:23 +0000
1"""Euclidean projection onto an affine set ``{x : C x = d}``."""
3from __future__ import annotations
5from collections.abc import Callable
6from typing import cast
8import numpy as np
10from ..core.exceptions import DimensionMismatchError, NotAMatrixError
11from ..core.types import Matrix, Vector
12from ..decomposition.cholesky import _factored_solver
15class AffineProjection:
16 """Euclidean projection onto the affine set ``{x : C x = d}``.
18 The projection of ``x`` is the nearest point (in the 2-norm) satisfying the
19 equality constraints,
21 ``P(x) = x - C.T @ (C C.T)^{-1} @ (C x - d)``.
23 The Gram matrix ``C C.T`` is formed once at construction (the ``O(mc**2 * n)``
24 cost) and factorised on the first projection, so repeated projections -- for
25 instance an alternating box / affine projection loop -- pay only ``O(mc**2)``
26 triangular solves each (with SciPy installed). ``C`` should have full row rank.
28 Args:
29 c: Constraint matrix ``C`` of shape ``(mc, n)``.
30 d: Constraint target ``d`` of shape ``(mc,)``.
32 Example:
33 >>> import numpy as np
34 >>> from cvx.linalg import AffineProjection
35 >>> proj = AffineProjection(np.ones((1, 3)), np.array([1.0]))
36 >>> p = proj.project(np.array([1.0, 1.0, 1.0]))
37 >>> bool(np.isclose(np.ones(3) @ p, 1.0)) # lands on the affine set
38 True
39 >>> np.allclose(p, [1 / 3, 1 / 3, 1 / 3]) # nearest point with sum 1
40 True
41 """
43 def __init__(self, c: Matrix, d: Vector) -> None:
44 """Store ``C`` and ``d`` and precompute the Gram matrix ``C C.T``."""
45 c = np.asarray(c, dtype=np.float64)
46 d = np.asarray(d, dtype=np.float64)
47 if c.ndim != 2:
48 raise NotAMatrixError(c.ndim, func="AffineProjection")
49 if d.ndim != 1 or d.shape[0] != c.shape[0]:
50 raise DimensionMismatchError(d.shape[0] if d.ndim == 1 else d.size, c.shape[0])
51 self._c = c
52 self._d = d
53 self._gram = c @ c.T
54 self._solve_gram: Callable[[Vector | Matrix], Vector | Matrix] | None = None
56 @property
57 def m(self) -> int:
58 """Number of constraints (rows of ``C``)."""
59 return int(self._c.shape[0])
61 @property
62 def n(self) -> int:
63 """Ambient dimension (columns of ``C``)."""
64 return int(self._c.shape[1])
66 def project(self, x: Vector | Matrix) -> Vector | Matrix:
67 """Return the Euclidean projection of ``x`` onto ``{x : C x = d}``.
69 Args:
70 x: Point of shape ``(n,)`` or a stack of points ``(n, k)``.
72 Returns:
73 The projected point(s), same shape as ``x``.
74 """
75 x = np.asarray(x, dtype=np.float64)
76 target = self._d if x.ndim == 1 else self._d[:, None]
77 residual = self._c @ x - target
78 correction = self._c.T @ self._gram_solver()(residual)
79 return x - correction
81 def _gram_solver(self) -> Callable[[Vector | Matrix], Vector | Matrix]:
82 """Return the solver for ``C C.T``, factorising it on first use.
84 A non-finite Gram matrix skips the factorisation and is LU-solved per
85 call, so NaNs in ``C`` propagate to the projection as before.
86 """
87 if self._solve_gram is None:
88 if np.all(np.isfinite(self._gram)):
89 self._solve_gram = _factored_solver(self._gram)
90 else:
91 gram = self._gram
92 self._solve_gram = lambda rhs: cast("Vector | Matrix", np.linalg.solve(gram, rhs))
93 return self._solve_gram