Active-Set CG Router / active_set_router.py
Beats tuned baseline
1import numpy as np
2
3
4def cg(matvec, rhs, tol=1e-10, max_iter=None):
5 """Conjugate gradients, returning x, iterations, and final relative residual."""
6 rhs = np.asarray(rhs, float)
7 n = len(rhs)
8 if max_iter is None:
9 max_iter = max(4 * n, 1000)
10 x = np.zeros(n)
11 r = rhs - matvec(x)
12 p = r.copy()
13 rr = float(r @ r)
14 r0 = max(np.sqrt(rr), 1e-30)
15 if r0 == 0:
16 return x, 0, 0.0
17 for k in range(1, max_iter + 1):
18 Ap = matvec(p)
19 den = float(p @ Ap)
20 if den <= 0 or not np.isfinite(den):
21 raise RuntimeError("CG encountered a non-SPD operator")
22 alpha = rr / den
23 x += alpha * p
24 r -= alpha * Ap
25 nr = np.linalg.norm(r)
26 if nr <= tol * r0:
27 return x, k, nr / r0
28 rr_new = float(r @ r)
29 p = r + (rr_new / rr) * p
30 rr = rr_new
31 return x, max_iter, np.linalg.norm(r) / r0
32
33
34def active_set_simplex(Z, b, lam=1e-3, tol=1e-10, pivot_tol=1e-9,
35 max_pivots=1000, cg_tol=1e-11):
36 """Solve min .5*x'(Z'Z+lam I)x-b'x, x>=0, sum(x)=1.
37
38 Products use Z and Z.T only. The equality constrained free solve uses two
39 CG solves (A_F y=b_F and A_F q=1), followed by a scalar Schur correction.
40 """
41 Z = np.asarray(Z, float); b = np.asarray(b, float)
42 n, E = Z.shape
43 F = list(range(E)); pivots = 0; cg_iters = 0
44 x = np.ones(E) / E
45 last_nu = 0.0
46 def Avec(v, inds):
47 return Z[:, inds].T @ (Z[:, inds] @ v) + lam * v
48 while pivots <= max_pivots:
49 inds = np.array(F, dtype=int)
50 Af = lambda v: Avec(v, inds)
51 y, it1, _ = cg(Af, b[inds], tol=cg_tol, max_iter=max(20, 8*len(F)))
52 q, it2, _ = cg(Af, np.ones(len(F)), tol=cg_tol, max_iter=max(20, 8*len(F)))
53 cg_iters += it1 + it2
54 denom = float(np.ones(len(F)) @ q)
55 nu = (float(np.ones(len(F)) @ y) - 1.0) / denom
56 xf = y - nu * q
57 x[:] = 0.0; x[inds] = xf; last_nu = nu
58 bad = [(v, i) for v, i in zip(xf, inds) if v < -pivot_tol]
59 if bad:
60 # Bland tie-break after most-negative pivot.
61 m = min(v for v, _ in bad)
62 i = min(i for v, i in bad if v <= m + 1e-14)
63 F.remove(i); pivots += 1
64 if not F: raise RuntimeError("empty free set")
65 continue
66 g = Z.T @ (Z @ x) + lam*x - b + last_nu
67 blocked = [i for i in range(E) if i not in F and g[i] < -pivot_tol]
68 if blocked:
69 m = min(g[i] for i in blocked)
70 i = min(i for i in blocked if g[i] <= m + 1e-14)
71 F.append(i); F.sort(); pivots += 1
72 continue
73 # Numerical cleanup is only after all sign decisions.
74 x[np.abs(x) < tol] = 0.0
75 return x, {"pivots": pivots, "cg_matvecs": cg_iters, "free": len(F),
76 "nu": last_nu, "gradient": g}
77 raise RuntimeError("active-set pivot limit reached")
78
79
80def projected_gradient(Z, b, lam=1e-3, steps=5000, tol=1e-12):
81 """Reference projected-gradient solve, with simplex projection."""
82 E = Z.shape[1]
83 L = np.linalg.eigvalsh(Z.T @ Z + lam*np.eye(E))[-1]
84 x = np.ones(E)/E
85 for k in range(steps):
86 grad = Z.T @ (Z @ x) + lam*x - b
87 xn = simplex_project(x - grad/L)
88 if np.linalg.norm(xn-x) < tol:
89 return xn, k+1
90 x = xn
91 return x, steps
92
93
94def simplex_project(v):
95 u = np.sort(v)[::-1]
96 cssv = np.cumsum(u) - 1
97 ind = np.arange(1, len(v)+1)
98 rho = np.nonzero(u - cssv/ind > 0)[0]
99 rho = rho[-1] if len(rho) else 0
100 theta = cssv[rho]/(rho+1)
101 return np.maximum(v-theta, 0.0)
102
103
104def objective(Z, b, x, lam):
105 return 0.5*(np.linalg.norm(Z@x)**2 + lam*np.dot(x,x)) - np.dot(b,x)