|
| 1 | +"""Online Schur--Ledoit-Wolf covariance: analytic, data-estimated cross-block damping. |
| 2 | +
|
| 3 | +Like :class:`SchurCovariance`, but the cross-block coupling damping ``gamma`` is not a |
| 4 | +hyperparameter -- it is *estimated* as the Ledoit & Wolf (2004) reliability of the |
| 5 | +cross-block coupling. Shrinking the cross-block entries toward zero, the Frobenius-optimal |
| 6 | +keep fraction is |
| 7 | +
|
| 8 | + gamma* = sum_cross sigma_ij^2 / sum_cross ( sigma_ij^2 + Var(s_ij) ) |
| 9 | + = 1 - (Ledoit-Wolf shrinkage intensity restricted to the cross-block block), |
| 10 | +
|
| 11 | +which is exactly the *coupling reliability*: it is small when the cross-block coupling is |
| 12 | +dominated by sampling noise and tends to 1 as it becomes well estimated, so the damping |
| 13 | +adapts to the effective sample size with no tuning. The required dispersion statistic is |
| 14 | +tracked incrementally (O(d^2) per step), in the manner of :class:`LedoitWolfCovariance`. |
| 15 | +At ``gamma_ -> 0`` the estimate is block-diagonal (HRP-like); at ``gamma_ -> 1`` it is the |
| 16 | +full EWA covariance. numpy only. |
| 17 | +""" |
| 18 | + |
| 19 | +from __future__ import annotations |
| 20 | + |
| 21 | +import numpy as np |
| 22 | + |
| 23 | +from precise._linalg import make_pos_def, to_symmetric |
| 24 | +from precise._state import emp_update, ewa_init |
| 25 | +from precise.base import BaseOnlineCovariance |
| 26 | + |
| 27 | + |
| 28 | +class SchurLedoitWolfCovariance(BaseOnlineCovariance): |
| 29 | + """Online Schur covariance with a Ledoit-Wolf-estimated cross-block damping. |
| 30 | +
|
| 31 | + :param r: Decay rate of the underlying EWA covariance, in (0, 1]. |
| 32 | + :param n_blocks: Number of contiguous blocks to partition the variables into. |
| 33 | + :param diff: If ``True``, estimate the covariance of first differences of the stream. |
| 34 | +
|
| 35 | + After fitting, the data-estimated damping is available as ``self.gamma_``. |
| 36 | + """ |
| 37 | + |
| 38 | + def __init__(self, r: float = 0.05, n_blocks: int = 4, diff: bool = False): |
| 39 | + self.r = r |
| 40 | + self.n_blocks = n_blocks |
| 41 | + self.diff = diff |
| 42 | + self.gamma_: float | None = None |
| 43 | + super().__init__() |
| 44 | + |
| 45 | + def _cross_mask(self, p: int) -> np.ndarray: |
| 46 | + nb = min(self.n_blocks, p) |
| 47 | + block_id = np.empty(p, dtype=int) |
| 48 | + for bi, idx in enumerate(np.array_split(np.arange(p), nb)): |
| 49 | + block_id[idx] = bi |
| 50 | + return block_id[:, None] != block_id[None, :] |
| 51 | + |
| 52 | + def _init_state(self, n_dim: int) -> dict: |
| 53 | + # State holds only plain accumulators (roundtrip-safe); the block mask is derived |
| 54 | + # from n_dim and n_blocks on demand, exactly as SchurCovariance does. |
| 55 | + s = ewa_init(n_dim, self.r) |
| 56 | + s["pi_cross"] = 0.0 # EWA mean cross-block scatter dispersion |
| 57 | + return s |
| 58 | + |
| 59 | + def _update_state(self, s: dict, x: np.ndarray) -> dict: |
| 60 | + if s["n_samples"] < s["n_burn"]: |
| 61 | + out = emp_update(s, x) |
| 62 | + out["r"], out["n_burn"], out["pi_cross"] = s["r"], s["n_burn"], s["pi_cross"] |
| 63 | + return out |
| 64 | + r = s["r"] |
| 65 | + cross = self._cross_mask(s["n_dim"]) |
| 66 | + delta = x - s["mean"] |
| 67 | + scatter = np.outer(delta, delta) |
| 68 | + q = float(np.sum(((scatter - s["cov"]) ** 2)[cross])) # cross-block dispersion, this step |
| 69 | + return { |
| 70 | + "n_dim": s["n_dim"], |
| 71 | + "n_samples": s["n_samples"] + 1, |
| 72 | + "mean": (1 - r) * s["mean"] + r * x, |
| 73 | + "cov": (1 - r) * s["cov"] + r * scatter, |
| 74 | + "r": r, |
| 75 | + "n_burn": s["n_burn"], |
| 76 | + "pi_cross": (1 - r) * s["pi_cross"] + r * q, |
| 77 | + } |
| 78 | + |
| 79 | + def _state_to_cov(self, state: dict) -> np.ndarray: |
| 80 | + cov = np.asarray(state["cov"], dtype=float) |
| 81 | + cross = self._cross_mask(state["n_dim"]) |
| 82 | + m2 = float(np.sum((cov ** 2)[cross])) # ~ sum (sigma^2 + Var) over cross |
| 83 | + b2 = float(state.get("pi_cross", 0.0)) * state["r"] # ~ sum Var(s) over cross (eff n ~ 1/r) |
| 84 | + gamma = float(np.clip(1.0 - b2 / m2, 0.0, 1.0)) if m2 > 0 else 1.0 |
| 85 | + self.gamma_ = gamma |
| 86 | + out = cov.copy() |
| 87 | + out[cross] *= gamma |
| 88 | + return make_pos_def(to_symmetric(out)) |
0 commit comments