Coverage for src/cvxcla/_projection.py: 100%
30 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-11 09:58 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-11 09:58 +0000
1"""Feasibility projection for Critical Line Algorithm turning points.
3Accumulated floating-point round-off over the many turning points of a long
4trace can place a candidate a hair outside its box even at a well-conditioned
5vertex: the covariance there has near-flat directions (its small eigenvalues)
6and the round-off lies in exactly those directions, so the candidate is optimal
7to solver precision but not exactly feasible. These pure helpers project such a
8candidate back onto the feasible region ``{w : lower <= w <= upper, C w = d}``.
10They are a strict no-op for the well-posed, already-feasible turning points that
11are the common case, so they never perturb the exact frontier. ``CLA._emit``
12calls :func:`project_feasible` at every turning point.
13"""
15from __future__ import annotations
17import numpy as np
18from cvx.linalg import AffineProjection
19from numpy.typing import NDArray
22def project_feasible(
23 weights: NDArray[np.float64],
24 lower: NDArray[np.float64],
25 upper: NDArray[np.float64],
26 a: NDArray[np.float64],
27 b: NDArray[np.float64],
28 g: NDArray[np.float64],
29 h: NDArray[np.float64],
30 active_ineq: NDArray[np.bool_],
31) -> NDArray[np.float64]:
32 """Project ``weights`` onto the feasible region, clearing round-off.
34 Well-posed turning points are already strictly feasible and are returned
35 unchanged so the projection never perturbs the exact frontier. Otherwise the
36 candidate is dispatched to the closed-form capped-simplex projection for the
37 canonical all-ones budget with no active inequality row (see
38 :func:`project_capped_simplex`), and to the general alternating projection
39 otherwise (see :func:`project_alternating`).
41 Args:
42 weights: The candidate weight vector to project.
43 lower: Per-asset lower bounds.
44 upper: Per-asset upper bounds.
45 a: Equality-constraint matrix ``A`` of ``A w = b``.
46 b: Equality-constraint right-hand side ``b``.
47 g: Inequality-constraint matrix ``G`` of ``G w <= h`` (``(p, n)``).
48 h: Inequality-constraint right-hand side ``h`` (length ``p``).
49 active_ineq: Boolean mask (length ``p``) of the active inequality rows.
51 Returns:
52 The projected weight vector, feasible to the box and the constraints.
53 """
54 # Well-posed turning points are already strictly feasible: return them
55 # unchanged so the projection never perturbs the exact frontier.
56 if np.all(weights >= lower) and np.all(weights <= upper):
57 return weights
59 if _is_capped_simplex(active_ineq, a):
60 return project_capped_simplex(weights, lower, upper, float(b[0]))
62 c = np.vstack([a, g[active_ineq]])
63 d = np.concatenate([b, h[active_ineq]])
64 return project_alternating(weights, lower, upper, c, d)
67def _is_capped_simplex(active_ineq: NDArray[np.bool_], a: NDArray[np.float64]) -> bool:
68 """Whether the projection reduces to the closed-form capped simplex.
70 True when no inequality row is active and the equality is the canonical
71 all-ones budget (a single row of ones), the case handled by
72 :func:`project_capped_simplex`.
74 Args:
75 active_ineq: Boolean mask of the active inequality rows.
76 a: Equality-constraint matrix ``A``.
78 Returns:
79 ``True`` if the closed-form capped-simplex projection applies.
80 """
81 return not active_ineq.any() and a.shape[0] == 1 and bool(np.allclose(a, 1.0))
84def project_capped_simplex(
85 weights: NDArray[np.float64],
86 lower: NDArray[np.float64],
87 upper: NDArray[np.float64],
88 budget: float,
89) -> NDArray[np.float64]:
90 """Euclidean projection onto the capped simplex for the all-ones budget.
92 Computed by water-filling a single shift ``theta`` so that
93 ``sum(clip(w - theta, lower, upper)) = budget``. A plain clip-then-rescale is
94 deliberately *not* used: rescaling the clipped weights to restore the budget
95 can push capped weights back over their bound when many assets are capped at
96 once (heavy ties under a tight cap), re-introducing the very infeasibility
97 the projection is meant to clear.
99 Args:
100 weights: The candidate weight vector, known to violate its box.
101 lower: Per-asset lower bounds.
102 upper: Per-asset upper bounds.
103 budget: The all-ones budget right-hand side ``sum(w)``.
105 Returns:
106 The projected weight vector, on the budget and inside the box.
107 """
108 # sum(clip(w - theta, lower, upper)) is non-increasing in theta; bisect
109 # for the theta that hits the budget. The bracket clips to all-upper
110 # (sum at least the budget) at theta_lo and all-lower (at most the
111 # budget) at theta_hi, so a root is guaranteed for any feasible problem.
112 theta_lo = float((weights - upper).min()) - 1.0
113 theta_hi = float((weights - lower).max()) + 1.0
114 for _ in range(100):
115 theta = 0.5 * (theta_lo + theta_hi)
116 if float(np.clip(weights - theta, lower, upper).sum()) > budget:
117 theta_lo = theta
118 else:
119 theta_hi = theta
120 return np.clip(weights - 0.5 * (theta_lo + theta_hi), lower, upper)
123def project_alternating(
124 weights: NDArray[np.float64],
125 lower: NDArray[np.float64],
126 upper: NDArray[np.float64],
127 c: NDArray[np.float64],
128 d: NDArray[np.float64],
129) -> NDArray[np.float64]:
130 """Alternating projection onto the box and the affine set ``{C w = d}``.
132 ``C`` stacks the equality rows ``A`` and the active inequality rows ``G_S``
133 (held at equality ``g_i w = h_i``). The candidate already satisfies
134 ``C w = d`` (the reduced KKT solve enforces it) and the inactive inequality
135 rows keep a margin, so a few iterations alternating a box clip with the
136 affine projection converge to a point feasible to the box, the equalities,
137 and every inequality.
139 Args:
140 weights: The candidate weight vector, known to violate its box.
141 lower: Per-asset lower bounds.
142 upper: Per-asset upper bounds.
143 c: Stacked equality/active-inequality matrix.
144 d: Stacked equality/active-inequality right-hand side.
146 Returns:
147 The projected weight vector, feasible to the box and the constraints.
148 """
149 affine = AffineProjection(c, d)
150 projected = weights
151 for _ in range(100):
152 projected = np.clip(projected, lower, upper)
153 projected = affine.project(projected)
154 return np.clip(projected, lower, upper)