Skip to content

Commit f5575e8

Browse files
Merge pull request #48 from microprediction/schur-ledoit-wolf
Add SchurLedoitWolfCovariance: analytic cross-block damping (γ* = coupling reliability)
2 parents 0e063c4 + 3c68f62 commit f5575e8

4 files changed

Lines changed: 152 additions & 0 deletions

File tree

precise/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
from precise.partialmoments import PartialMomentsCovariance
3737
from precise.recommend import covariance_features, suggest
3838
from precise.registry import all_estimators, estimator_from_name, estimator_names
39+
from precise.schur_ledoit_wolf import SchurLedoitWolfCovariance
3940
from precise.schurcov import SchurCovariance
4041
from precise.shrunk import ShrunkCovariance
4142
from precise.tyler import TylerCovariance
@@ -55,6 +56,7 @@
5556
"OASCovariance",
5657
"ShrunkCovariance",
5758
"SchurCovariance",
59+
"SchurLedoitWolfCovariance",
5860
"PartialMomentsCovariance",
5961
"HuberCovariance",
6062
"TylerCovariance",

precise/registry.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from precise.ledoitwolf import LedoitWolfCovariance
2020
from precise.oas import OASCovariance
2121
from precise.partialmoments import PartialMomentsCovariance
22+
from precise.schur_ledoit_wolf import SchurLedoitWolfCovariance
2223
from precise.schurcov import SchurCovariance
2324
from precise.shrunk import ShrunkCovariance
2425
from precise.tyler import TylerCovariance
@@ -33,6 +34,7 @@
3334
OASCovariance,
3435
ShrunkCovariance,
3536
SchurCovariance,
37+
SchurLedoitWolfCovariance,
3638
PartialMomentsCovariance,
3739
HuberCovariance,
3840
TylerCovariance,

precise/schur_ledoit_wolf.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
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))

tests/test_schur_ledoit_wolf.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
"""Tests specific to SchurLedoitWolfCovariance (the analytic cross-block damping).
2+
3+
The estimator contract (partial_fit, fitted attributes, fit==stream, state roundtrip) is
4+
already exercised by the parametrized suite in test_estimators.py, since the class is in
5+
the registry. Here we check the behaviour that is special to it: the data-estimated
6+
damping gamma_ is a valid reliability that tracks the coupling.
7+
"""
8+
from __future__ import annotations
9+
10+
import numpy as np
11+
12+
from precise import SchurLedoitWolfCovariance, all_estimators
13+
14+
15+
def _block_data(n, p=12, n_blocks=3, within=0.7, cross=0.2, seed=0):
16+
rng = np.random.default_rng(seed)
17+
g = np.arange(p) // (p // n_blocks)
18+
C = cross + (within - cross) * (g[:, None] == g[None, :])
19+
np.fill_diagonal(C, 1.0)
20+
return rng.standard_normal((n, p)) @ np.linalg.cholesky(C).T
21+
22+
23+
def test_registered():
24+
assert SchurLedoitWolfCovariance in all_estimators()
25+
26+
27+
def test_gamma_in_unit_interval_and_pd():
28+
e = SchurLedoitWolfCovariance(n_blocks=3, r=0.02)
29+
e.partial_fit(_block_data(500))
30+
C = e.covariance_
31+
assert 0.0 <= e.gamma_ <= 1.0
32+
assert np.all(np.linalg.eigvalsh(C) > 0)
33+
34+
35+
def _gamma(cross, r=0.02, n=2000):
36+
e = SchurLedoitWolfCovariance(n_blocks=3, r=r)
37+
e.partial_fit(_block_data(n, within=0.7, cross=cross, seed=1))
38+
_ = e.covariance_
39+
return e.gamma_
40+
41+
42+
def test_gamma_rises_with_coupling_strength():
43+
# stronger true cross-block coupling => higher reliability => larger gamma_
44+
# (the right invariant for an EWA estimator, whose effective sample is ~1/r, not n)
45+
g = [_gamma(c) for c in (0.0, 0.1, 0.3, 0.6)]
46+
assert g[0] < g[1] < g[2] < g[3]
47+
48+
49+
def test_gamma_rises_as_decay_shrinks():
50+
# smaller r => larger effective sample => the coupling is more reliably estimated
51+
assert _gamma(0.5, r=0.05) < _gamma(0.5, r=0.005)
52+
53+
54+
def test_gamma_low_when_coupling_is_noise():
55+
# independent columns => no reliable cross-block coupling => heavy damping (small gamma_)
56+
rng = np.random.default_rng(2)
57+
e = SchurLedoitWolfCovariance(n_blocks=3, r=0.02)
58+
e.partial_fit(rng.standard_normal((2000, 12)))
59+
_ = e.covariance_
60+
assert e.gamma_ < 0.5

0 commit comments

Comments
 (0)