Coverage for src/fast_minimum_variance/shrinkage/util.py: 100%
57 statements
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-02 13:28 +0000
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-02 13:28 +0000
1"""Shrinkage intensity and target utilities (LW, OAS, RMT, constant-correlation)."""
3import numpy as np
4from sklearn.covariance import ledoit_wolf, oas
7def lw_alpha_and_target(X: np.ndarray) -> tuple[float, np.ndarray]: # noqa: N803
8 """Return (alpha_lw, target) for LW scaled-identity shrinkage via sklearn.
10 X must be column-demeaned. The shrinkage target is bar_lambda * I where
11 bar_lambda = ||X||_F^2 / (n * T) is the average per-asset variance
12 (equivalently, the mean eigenvalue of the sample covariance X^T X / T).
13 """
14 _, alpha = ledoit_wolf(X, assume_centered=True)
15 n = X.shape[1]
16 T = X.shape[0] # noqa: N806
17 bar_lam = float(np.linalg.norm(X, "fro") ** 2) / (n * T)
18 cov = (X.transpose() @ X) / T
19 bar_lam_cov = np.linalg.trace(cov) / n
20 assert np.isclose(bar_lam, bar_lam_cov) # noqa: S101
22 delta2 = np.linalg.norm(cov - bar_lam * np.eye(n), "fro") ** 2
23 norms_sq = np.sum(X**2, axis=1)
24 beta2 = (np.sum(norms_sq**2) - 2 * np.sum(X * (X @ cov)) + T * np.linalg.norm(cov, "fro") ** 2) / T**2
25 alpha_manual = min(1.0, beta2 / delta2)
26 assert np.isclose(alpha_manual, alpha) # noqa: S101
28 return float(alpha), bar_lam * np.eye(n)
31def lw_alpha_and_target_hard(X: np.ndarray, alpha: float = 0.5) -> tuple[float, np.ndarray]: # noqa: N803
32 """Return (alpha, target) for scaled-identity shrinkage with a fixed alpha.
34 X must be column-demeaned. The shrinkage target is bar_lambda * I where
35 bar_lambda = ||X||_F^2 / (n * T) is the average per-asset variance
36 (equivalently, the mean eigenvalue of the sample covariance X^T X / T).
37 """
38 # _, alpha = ledoit_wolf(X, assume_centered=True)
39 n = X.shape[1]
40 T = X.shape[0] # noqa: N806
41 bar_lam = float(np.linalg.norm(X, "fro") ** 2) / (n * T)
43 return alpha, bar_lam * np.eye(n)
46def oas_alpha_and_target(X: np.ndarray) -> tuple[float, np.ndarray]: # noqa: N803
47 """Return (alpha_oas, target) for OAS scaled-identity shrinkage via sklearn.
49 Uses the same bar_lambda * I target as LW but the Oracle Approximating
50 Shrinkage formula (Chen et al. 2010), which has lower MSE when n/T is
51 non-negligible.
52 """
53 _, alpha = oas(X, assume_centered=True)
54 n = X.shape[1]
55 bar_lam = float(np.linalg.norm(X, "fro") ** 2) / (n * X.shape[0])
56 return float(alpha), bar_lam * np.eye(n)
59def cc_target(X: np.ndarray) -> tuple[np.ndarray, float]: # noqa: N803
60 """Constant-correlation shrinkage target (Ledoit-Wolf 2004 JoPM).
62 T0_ij = rho_bar * sigma_i * sigma_j (i != j)
63 T0_ii = sigma_i^2
65 where sigma_i = sqrt(Sigma_ii) is the per-asset sample standard deviation
66 and rho_bar is the mean off-diagonal sample correlation coefficient.
67 Always PSD for 0 < rho_bar < 1.
69 Returns (target, rho_bar).
70 """
71 T, n = X.shape # noqa: N806
72 cov = (X.T @ X) / T
73 std = np.sqrt(np.diag(cov))
74 corr = cov / np.outer(std, std)
75 np.fill_diagonal(corr, 0.0)
76 rho_bar = float(corr.sum()) / (n * (n - 1))
77 target = rho_bar * np.outer(std, std)
78 np.fill_diagonal(target, std**2)
79 return target, rho_bar
82def lw_alpha_for_target(X: np.ndarray, target: np.ndarray) -> float: # noqa: N803
83 """LW oracle alpha for an arbitrary SPD shrinkage target T0.
85 alpha* = min(1, beta2 / delta2) where
86 delta2 = ||S - T0||_F^2 (distance of sample cov from target)
87 beta2 = (1/T^2) sum_t ||x_t x_t' - S||_F^2 (noise in S)
88 """
89 T, _n = X.shape # noqa: N806
90 cov = (X.T @ X) / T
91 delta2 = float(np.linalg.norm(cov - target, "fro") ** 2)
92 norms_sq = np.sum(X**2, axis=1)
93 beta2 = float((np.sum(norms_sq**2) - 2 * np.sum(X * (X @ cov)) + T * np.linalg.norm(cov, "fro") ** 2) / T**2)
94 return min(1.0, beta2 / delta2)
97def rmt_target_and_alpha(
98 X: np.ndarray, # noqa: N803
99) -> tuple[np.ndarray, tuple[float, np.ndarray, np.ndarray], int, float]:
100 """RMT-clipped shrinkage target with alpha=1.
102 Eigenvalues of the sample covariance above the Marchenko-Pastur bulk edge
103 are kept as-is (signal); all others are clipped to bar_lambda (noise floor).
104 The resulting target has lambda_min = bar_lambda (same as the scaled-identity
105 target) so it provides equally effective lambda_min lifting at any alpha, while
106 being 9x closer to the sample covariance in Frobenius norm than bar_lambda * I.
108 T0 = bar_lambda * I + U_k @ diag(lambda_k - bar_lambda) @ U_k^T
110 where (U_k, lambda_k) are the k eigenpairs of S = X^T X / T whose eigenvalues
111 exceed the MP upper edge bar_lambda * (1 + sqrt(n/T))^2.
113 Returns (target, lr_factors, k, 1.0). alpha=1 means the system matrix is
114 T0^RMT directly; the _kkt_step Woodbury path applies this in O(n_a k + k^3).
115 """
116 T, n = X.shape # noqa: N806
117 cov = (X.T @ X) / T
118 bar_lam = np.trace(cov) / n
119 mp_upper = bar_lam * (1.0 + np.sqrt(n / T)) ** 2
121 eigs, vecs = np.linalg.eigh(cov) # ascending order
122 signal = eigs > mp_upper
123 k = int(signal.sum())
124 eigs_k = eigs[signal]
125 vecs_k = vecs[:, signal]
127 delta_k = eigs_k - bar_lam # (k,) eigenvalue excesses
128 target = bar_lam * np.eye(n) + vecs_k @ np.diag(delta_k) @ vecs_k.T
129 lr_factors = (float(bar_lam), vecs_k, delta_k) # for O(nk) matvec
130 return target, lr_factors, k, 1.0