From de9bbba025374847eab7b6cf5ad095e3ec80861a Mon Sep 17 00:00:00 2001 From: Max Halford Date: Thu, 25 Jun 2026 13:17:14 +0200 Subject: [PATCH 1/4] feat(covariance): add online EWA and shrinkage covariance/precision estimators Add a family of online covariance estimators reimplemented from the `precise` package, following River conventions (dict-native, Narwhals mini-batches) and excluding any lazy invert-on-read methods: - covariance.EwaCovariance: exponentially weighted covariance (RiskMetrics style); diagonal matches stats.EWVar, off-diagonals match stats.EWCov. - covariance.LedoitWolfCovariance / OASCovariance: data-driven shrinkage towards a scaled identity for high-dimensional / few-sample regimes. - covariance.ShrunkCovariance: fixed-intensity shrinkage with a constant-correlation (finance) or identity target. - covariance.EwaPrecision: exponentially weighted precision via a forgetting-factor Sherman-Morrison update; genuinely online, never inverts explicitly. Recency-weighted counterpart of EmpiricalPrecision. - stats.EWCov: exponentially weighted covariance primitive (bivariate counterpart of stats.EWVar). - datasets.SP500Stocks: daily returns of ten large-cap S&P 500 stocks (2013-2018), used in the docstring examples. Internals are array-backed with a feature->index map (like EmpiricalPrecision) behind a dict-native interface. Also guards SymmetricMatrix.__repr__ against empty (unfitted) matrices. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/releases/unreleased.md | 4 + river/covariance/__init__.py | 31 +- river/covariance/emp.py | 4 + river/covariance/ewa.py | 560 +++++++++++++++++++++++++++++++++++ river/covariance/test_ewa.py | 322 ++++++++++++++++++++ river/datasets/__init__.py | 2 + river/datasets/sp500.csv.gz | Bin 0 -> 59118 bytes river/datasets/sp500.py | 46 +++ river/stats/__init__.py | 2 + river/stats/ewcov.py | 65 ++++ 10 files changed, 1034 insertions(+), 2 deletions(-) create mode 100644 river/covariance/ewa.py create mode 100644 river/covariance/test_ewa.py create mode 100644 river/datasets/sp500.csv.gz create mode 100644 river/datasets/sp500.py create mode 100644 river/stats/ewcov.py diff --git a/docs/releases/unreleased.md b/docs/releases/unreleased.md index ee0b08745b..48fcc81c3d 100644 --- a/docs/releases/unreleased.md +++ b/docs/releases/unreleased.md @@ -15,6 +15,8 @@ ## covariance +- Added `EwaCovariance`, `LedoitWolfCovariance`, `OASCovariance`, and `ShrunkCovariance`: online covariance estimators for non-stationary streams (exponentially weighted, recency-biased) and high-dimensional / few-sample regimes (shrinkage towards a well-conditioned target). They are dict-native like `EmpiricalCovariance` and support mini-batches via `update_many` on any [narwhals](https://github.com/narwhals-dev/narwhals)-supported eager backend. +- Added `EwaPrecision`, an exponentially weighted precision (inverse covariance) matrix maintained online via a forgetting-factor Sherman-Morrison update. The recency-weighted counterpart of `EmpiricalPrecision`, useful for tracking Mahalanobis distances and Gaussian likelihoods on non-stationary streams. - Added weighted sample support to `EmpiricalCovariance.update` and `EmpiricalCovariance.revert` by accepting an optional `w` parameter and propagating it to the underlying `stats.Cov` and `stats.Var` statistics. - Sped up `EmpiricalCovariance.update`/`revert` (~40% faster at 30 features) by caching the sorted feature list and pair iteration in the hot path. No semantic change. - Restructured `EmpiricalPrecision` around NumPy-backed dense state, removing the per-update dict ↔ numpy marshalling. ~7× faster on 2000 × 20 sample streams. @@ -24,6 +26,7 @@ - Added `datasets.CriteoAds`, a 100,000-row sample of the Criteo Display Advertising Challenge (binary click prediction with 13 integer and 26 high-cardinality categorical features). A natural fit for one-hot models such as `linear_model.AdPredictor`. - Added `datasets.Shuttle`, the UCI Statlog (Shuttle) dataset cast as a binary anomaly-detection task following the ODDS benchmark (49,097 observations, 9 numerical features, ~7% anomalies). Ships bundled with River. +- Added `datasets.SP500Stocks`, daily returns (1,257 trading days, 2013-2018) for ten large-cap S&P 500 stocks across diverse sectors. A natural fit for the online covariance estimators in `river.covariance`. ## facto @@ -91,6 +94,7 @@ ## stats +- Added `stats.EWCov`, an exponentially weighted covariance between two variables (the bivariate counterpart of `stats.EWVar`). - Added `stats.ChiSquared`, a streaming Chi-squared statistic between two categorical variables. Wrap it with `utils.Rolling` for a rolling version. ## stream diff --git a/river/covariance/__init__.py b/river/covariance/__init__.py index a026e17807..9413f778f8 100644 --- a/river/covariance/__init__.py +++ b/river/covariance/__init__.py @@ -1,7 +1,34 @@ -"""Online estimation of covariance and precision matrices.""" +"""Online estimation of covariance and precision matrices. + +A covariance matrix summarises how a set of variables move together. It is the engine behind +portfolio risk, anomaly detection (via the Mahalanobis distance), Gaussian models, and many +dimensionality-reduction methods. This module estimates it (and its inverse, the precision +matrix) incrementally from a stream, without storing the data. See each estimator's docstring for +what it does and when to reach for it. + +The estimators are dict-native: `update(x)` takes a mapping and the `matrix` is a dict of pairwise +values. Most also expose an `update_many` method for mini-batches of any narwhals-compatible +dataframe. + +""" from __future__ import annotations from .emp import EmpiricalCovariance, EmpiricalPrecision +from .ewa import ( + EwaCovariance, + EwaPrecision, + LedoitWolfCovariance, + OASCovariance, + ShrunkCovariance, +) -__all__ = ["EmpiricalCovariance", "EmpiricalPrecision"] +__all__ = [ + "EmpiricalCovariance", + "EmpiricalPrecision", + "EwaCovariance", + "EwaPrecision", + "LedoitWolfCovariance", + "OASCovariance", + "ShrunkCovariance", +] diff --git a/river/covariance/emp.py b/river/covariance/emp.py index e442be4a23..05160b6b44 100644 --- a/river/covariance/emp.py +++ b/river/covariance/emp.py @@ -33,6 +33,8 @@ def __getitem__(self, key): def __repr__(self): names = sorted({i for i, _ in self.matrix}) + if not names: + return f"{type(self).__name__} (empty)" headers = [""] + list(map(str, names)) columns = [headers[1:]] @@ -215,6 +217,7 @@ def _update_from_state(self, n: int, mean: dict, cov: float | dict): Raises ---------- KeyError: If an element in `mean` or `cov` is missing. + """ for i, j in itertools.combinations(sorted(mean.keys()), r=2): try: @@ -264,6 +267,7 @@ def _from_state(cls, n: int, mean: dict, cov: float | dict, *, ddof=1): Returns ---------- cls: A new instance of the class with updated covariance matrix. + """ new = cls(ddof=ddof) new._update_from_state(n=n, mean=mean, cov=cov) diff --git a/river/covariance/ewa.py b/river/covariance/ewa.py new file mode 100644 index 0000000000..011e208760 --- /dev/null +++ b/river/covariance/ewa.py @@ -0,0 +1,560 @@ +from __future__ import annotations + +import typing + +import numpy as np + +from river.utils import dataframe as dataframe_utils + +from .emp import SymmetricMatrix + +if typing.TYPE_CHECKING: + from narwhals.stable.v2.typing import IntoDataFrame + +__all__ = [ + "EwaCovariance", + "EwaPrecision", + "LedoitWolfCovariance", + "OASCovariance", + "ShrunkCovariance", +] + +_EPS = 1e-12 + + +class _EWMatrix(SymmetricMatrix): + """Array-backed exponentially weighted matrix estimator with a dict-native interface. + + Shared engine for the exponentially weighted covariance and precision estimators. It keeps a + running exponentially weighted mean vector (using the same convention as `stats.EWMean`) plus a + matrix-valued state, stored as dense numpy arrays with a feature to index map (the same pattern + as `covariance.EmpiricalPrecision`). A consistent set of features is assumed across + observations, mirroring the usual covariance-estimation setting; new features seen mid-stream + grow the matrix. + """ + + def __init__(self, fading_factor: float = 0.5): + if not 0 <= fading_factor <= 1: + raise ValueError("fading_factor is not comprised between 0 and 1") + self.fading_factor = float(fading_factor) + self._features: list = [] + self._idx: dict = {} + self._mean = np.zeros(0, dtype=np.float64) + self._initialized = np.zeros(0, dtype=bool) + self._n = 0 + self._matrix_cache: np.ndarray | None = None + + # --------------------------------------------------------------- internals + def _on_grow(self, old_dim: int, new_dim: int) -> None: + """Resize the subclass's matrix state when new features appear.""" + raise NotImplementedError + + def _grow(self, new_keys: list) -> None: + old_dim = len(self._features) + for k in new_keys: + self._idx[k] = len(self._features) + self._features.append(k) + new_dim = len(self._features) + mean = np.zeros(new_dim, dtype=np.float64) + mean[:old_dim] = self._mean + init = np.zeros(new_dim, dtype=bool) + init[:old_dim] = self._initialized + self._mean, self._initialized = mean, init + self._on_grow(old_dim, new_dim) + + def _vector(self, x: dict) -> np.ndarray: + new_keys = [k for k in x if k not in self._idx] + if new_keys: + self._grow(new_keys) + try: + return np.array([x[f] for f in self._features], dtype=np.float64) + except KeyError as e: + raise ValueError( + f"observation is missing feature {e.args[0]!r}; the exponentially weighted " + "estimators assume a consistent set of features" + ) from e + + def _fresh(self) -> np.ndarray: + return ~self._initialized + + def _blend_mean(self, v: np.ndarray, fresh: np.ndarray) -> np.ndarray: + f = self.fading_factor + # Seed freshly-seen features with the observed value (like stats.EWMean's first step). + if fresh.any(): + return np.where(fresh, v, (1 - f) * self._mean + f * v) + return (1 - f) * self._mean + f * v + + def _learn_vector(self, v: np.ndarray) -> None: + raise NotImplementedError + + def _to_matrix_array(self) -> np.ndarray: + raise NotImplementedError + + def _matrix_array(self) -> np.ndarray: + if self._matrix_cache is None: + self._matrix_cache = self._to_matrix_array() + return self._matrix_cache + + # -------------------------------------------------------------- public API + def update(self, x: dict): + """Update with a single sample. + + Parameters + ---------- + x + A sample. + + """ + self._learn_vector(self._vector(x)) + + def update_many(self, X: IntoDataFrame): + """Update with a dataframe of samples. + + Any [narwhals](https://github.com/narwhals-dev/narwhals)-compatible eager dataframe + (pandas, polars, pyarrow, ...) is accepted. The result is identical to feeding the rows + one at a time with `update`, in row order. + + Parameters + ---------- + X + A dataframe of samples. + + """ + frame = dataframe_utils.into_frame(X) + columns = list(frame.columns) + new_keys = [k for k in columns if k not in self._idx] + if new_keys: + self._grow(new_keys) + ids = [self._idx[c] for c in columns] + # Lay each row out in the estimator's feature order before the sequential update. + arr = dataframe_utils.to_numpy(frame) + layout = np.empty((arr.shape[0], len(self._features)), dtype=np.float64) + layout[:] = self._mean # carry forward known features absent from this batch + layout[:, ids] = arr + for row in layout: + self._learn_vector(row) + + @property + def matrix(self) -> dict: + if not self._features: + return {} + arr = self._matrix_array() + out = {} + for ai, fa in enumerate(self._features): + for bi in range(ai, len(self._features)): + fb = self._features[bi] + out[min((fa, fb), (fb, fa))] = float(arr[ai, bi]) + return out + + def __getitem__(self, key): + i, j = key + ai = self._idx.get(i) + bi = self._idx.get(j) + if ai is None or bi is None: + raise KeyError(key) + return float(self._matrix_array()[ai, bi]) + + +class _EWCovariance(_EWMatrix): + """Exponentially weighted covariance engine. + + Tracks an exponentially weighted mean vector and second-moment matrix and exposes the + covariance ``Σ = E[xxᵀ] - E[x]E[x]ᵀ``. The diagonal matches `stats.EWVar` and the off-diagonals + match `stats.EWCov`. Shrinkage subclasses override `_to_matrix_array`. + """ + + def __init__(self, fading_factor: float = 0.5): + super().__init__(fading_factor) + self._M2 = np.zeros((0, 0), dtype=np.float64) + + def _on_grow(self, old_dim: int, new_dim: int) -> None: + M2 = np.zeros((new_dim, new_dim), dtype=np.float64) + M2[:old_dim, :old_dim] = self._M2 + self._M2 = M2 + + def _before_update(self, v: np.ndarray) -> None: + """Hook called with the new observation before the state is updated (mean/M2 are old).""" + + def _learn_vector(self, v: np.ndarray) -> None: + self._before_update(v) + f = self.fading_factor + fresh = self._fresh() + outer = np.outer(v, v) + self._mean = self._blend_mean(v, fresh) + if fresh.any(): + fresh_pair = fresh[:, None] | fresh[None, :] + self._M2 = np.where(fresh_pair, outer, (1 - f) * self._M2 + f * outer) + else: + self._M2 = (1 - f) * self._M2 + f * outer + self._initialized[fresh] = True + self._n += 1 + self._matrix_cache = None + + def _raw_cov(self) -> np.ndarray: + return self._M2 - np.outer(self._mean, self._mean) + + def _to_matrix_array(self) -> np.ndarray: + return self._raw_cov() + + +class EwaCovariance(_EWCovariance): + """Exponentially weighted covariance matrix. + + A recency-weighted estimate of the covariance: each new observation is blended into the + estimate with weight ``fading_factor``, so the influence of past observations decays + geometrically. This is the streaming analogue of the RiskMetrics covariance and the matrix + counterpart of `stats.EWVar` / `stats.EWCov` (the diagonal is exactly `stats.EWVar` and each + off-diagonal is exactly `stats.EWCov`). + + **When to use it.** Reach for this over `EmpiricalCovariance` when the relationships between + your variables change over time. The textbook example is asset returns, whose volatilities and + correlations move with the market regime: a plain empirical covariance weights a return from + years ago the same as yesterday's, whereas an exponentially weighted one forgets the distant + past so the risk estimate tracks current conditions. Larger `fading_factor` reacts faster + (shorter memory); smaller is smoother. + + Parameters + ---------- + fading_factor + The closer `fading_factor` is to 1 the more weight recent observations carry. The + effective memory is roughly ``1 / fading_factor`` observations. + + Examples + -------- + + We estimate the covariance of daily returns (in percent) for a few stocks from the + `datasets.SP500Stocks` dataset. + + >>> from river import covariance, datasets + + >>> tickers = ["AAPL", "JPM", "XOM"] + >>> cov = covariance.EwaCovariance(fading_factor=0.02) + >>> for x, _ in datasets.SP500Stocks(): + ... cov.update({t: x[t] for t in tickers}) + >>> cov + AAPL JPM XOM + AAPL 1.944 0.766 0.760 + JPM 0.766 1.492 0.934 + XOM 0.760 0.934 1.705 + + There is also an `update_many` method to process mini-batches. It accepts any + narwhals-compatible dataframe and yields the same result as feeding the rows one by one. + + >>> import pandas as pd + >>> returns = pd.DataFrame(x for x, _ in datasets.SP500Stocks())[tickers] + >>> cov = covariance.EwaCovariance(fading_factor=0.02) + >>> cov.update_many(returns) + >>> cov + AAPL JPM XOM + AAPL 1.944 0.766 0.760 + JPM 0.766 1.492 0.934 + XOM 0.760 0.934 1.705 + + Individual entries are accessible by key: + + >>> cov["AAPL", "JPM"] + 0.766... + + References + ---------- + [^1]: [RiskMetrics Technical Document (J.P. Morgan, 1996)](https://www.msci.com/documents/10199/5915b101-4206-4ba0-aee2-3449d5c7e95a) + + """ + + +class LedoitWolfCovariance(_EWCovariance): + """Online Ledoit-Wolf shrinkage covariance. + + Shrinks the exponentially weighted sample covariance towards a scaled identity (a sphere) by a + data-driven intensity, following Ledoit & Wolf (2004). The shrinkage intensity is estimated + online from the dispersion of the per-observation scatter around the running covariance, and is + recomputed on read; the per-step update stays O(d^2) with no stored window. + + **When to use it.** The raw sample covariance is a poor estimate when the number of variables + is large relative to the (effective) number of observations: it is noisy and often + ill-conditioned or singular, which wrecks anything that inverts it (portfolio optimisation, + Mahalanobis distances, Gaussian likelihoods). Shrinkage trades a little bias for a large + reduction in variance, producing a well-conditioned, invertible matrix. Ledoit-Wolf picks the + shrinkage intensity for you, so it is a strong default when there are many assets relative to + clean history. + + Parameters + ---------- + fading_factor + Recency weight of the underlying exponentially weighted covariance. The effective sample + size is roughly ``1 / fading_factor``. + + Examples + -------- + + On the ten stocks of `datasets.SP500Stocks` with a short effective memory, the raw covariance is + poorly conditioned; Ledoit-Wolf shrinkage tames it. + + >>> import numpy as np + >>> from river import covariance, datasets + + >>> ewa = covariance.EwaCovariance(fading_factor=0.05) + >>> lw = covariance.LedoitWolfCovariance(fading_factor=0.05) + >>> for x, _ in datasets.SP500Stocks(): + ... ewa.update(x) + ... lw.update(x) + + >>> def condition_number(cov): + ... names = sorted({i for i, _ in cov.matrix}) + ... M = np.array([[cov[i, j] for j in names] for i in names]) + ... return np.linalg.cond(M) + + >>> condition_number(ewa) > condition_number(lw) + np.True_ + + References + ---------- + [^1]: [Ledoit, O. and Wolf, M., 2004. A well-conditioned estimator for large-dimensional covariance matrices. Journal of Multivariate Analysis, 88(2), pp.365-411.](https://www.ledoit.net/honey.pdf) + + """ + + def __init__(self, fading_factor: float = 0.5): + super().__init__(fading_factor) + self._pi_bar = 0.0 # running dispersion of the per-sample scatter about the covariance + + def _before_update(self, v: np.ndarray) -> None: + d = len(v) + if d == 0: + return + dev = v - self._mean + scatter = np.outer(dev, dev) + cov_old = self._raw_cov() + q = float(((scatter - cov_old) ** 2).sum() / d) + f = self.fading_factor + self._pi_bar = (1 - f) * self._pi_bar + f * q + + def _to_matrix_array(self) -> np.ndarray: + S = self._raw_cov() + d = S.shape[0] + mu = np.trace(S) / d + identity = np.eye(d) + disp = float(((S - mu * identity) ** 2).sum() / d) + if disp <= _EPS or self._pi_bar <= 0: + return S + # Effective sample size of the exponentially weighted estimator is ~ 1 / fading_factor. + b2 = min(self._pi_bar * self.fading_factor, disp) + intensity = float(np.clip(b2 / disp, 0.0, 1.0)) + return (1 - intensity) * S + intensity * mu * identity + + +class OASCovariance(_EWCovariance): + """Online Oracle Approximating Shrinkage (OAS) covariance. + + Like `LedoitWolfCovariance`, OAS shrinks the exponentially weighted sample covariance towards a + scaled identity, but uses the Chen, Wiesel, Eldar & Hero (2010) shrinkage intensity, which is + often better conditioned for approximately Gaussian data. The intensity is a closed form in the + traces of the running covariance, applied on read; the per-step update is a plain O(d^2) + exponentially weighted covariance update. + + **When to use it.** The same high-dimensional / few-sample situations as + `LedoitWolfCovariance`. OAS tends to shrink slightly more aggressively and is a good choice when + the data are close to Gaussian; otherwise the two are interchangeable and worth comparing. + + Parameters + ---------- + fading_factor + Recency weight of the underlying exponentially weighted covariance. The effective sample + size is roughly ``1 / fading_factor``. + + Examples + -------- + + >>> import numpy as np + >>> from river import covariance, datasets + + >>> oas = covariance.OASCovariance(fading_factor=0.05) + >>> for x, _ in datasets.SP500Stocks(): + ... oas.update(x) + + The shrinkage keeps the matrix positive-definite and invertible: + + >>> names = sorted({i for i, _ in oas.matrix}) + >>> M = np.array([[oas[i, j] for j in names] for i in names]) + >>> bool(np.all(np.linalg.eigvalsh(M) > 0)) + True + + References + ---------- + [^1]: [Chen, Y., Wiesel, A., Eldar, Y.C. and Hero, A.O., 2010. Shrinkage algorithms for MMSE covariance estimation. IEEE Transactions on Signal Processing, 58(10), pp.5016-5029.](https://arxiv.org/abs/0907.4698) + + """ + + def _to_matrix_array(self) -> np.ndarray: + S = self._raw_cov() + d = S.shape[0] + n = max(1.0 / self.fading_factor, 2.0) + tr = np.trace(S) + tr2 = np.trace(S @ S) + mu = tr / d + num = (1.0 - 2.0 / d) * tr2 + tr * tr + den = (n + 1.0 - 2.0 / d) * (tr2 - tr * tr / d) + rho = 1.0 if den <= 0 else min(max(num / den, 0.0), 1.0) + return (1.0 - rho) * S + rho * mu * np.eye(d) + + +class ShrunkCovariance(_EWCovariance): + """Fixed-intensity shrinkage covariance, towards a constant-correlation or identity target. + + Where `LedoitWolfCovariance` and `OASCovariance` estimate the shrinkage intensity from the + data, `ShrunkCovariance` uses a fixed `delta` (transparent and predictable, mirroring + `sklearn.covariance.ShrunkCovariance`) and offers a choice of target. + + **When to use it.** When you want explicit, reproducible control over how much regularisation is + applied rather than letting the data decide. The **constant-correlation** target (every pair + shrunk towards the average sample correlation) is the finance-relevant default: assets share a + positive baseline correlation, so pulling towards that is more sensible than pulling towards the + zero-correlation identity target. + + Parameters + ---------- + fading_factor + Recency weight of the underlying exponentially weighted covariance. + delta + Shrinkage intensity in [0, 1] (0 = the raw covariance, 1 = the target). + target + Either ``"constant_correlation"`` (default) or ``"identity"``. + + Examples + -------- + + >>> from river import covariance, datasets + + >>> tickers = ["AAPL", "JPM", "XOM"] + >>> cov = covariance.ShrunkCovariance(fading_factor=0.02, delta=0.3) + >>> for x, _ in datasets.SP500Stocks(): + ... cov.update({t: x[t] for t in tickers}) + >>> cov + AAPL JPM XOM + AAPL 1.944 0.784 0.797 + JPM 0.784 1.492 0.885 + XOM 0.797 0.885 1.705 + + With ``delta=0`` the estimator reduces to a plain exponentially weighted covariance, and with + ``delta=1`` it returns the target exactly. + + References + ---------- + [^1]: [Ledoit, O. and Wolf, M., 2003. Improved estimation of the covariance matrix of stock returns with an application to portfolio selection. Journal of Empirical Finance, 10(5), pp.603-621.](https://www.ledoit.net/ole2.pdf) + + """ + + def __init__( + self, + fading_factor: float = 0.5, + delta: float = 0.1, + target: str = "constant_correlation", + ): + if target not in ("constant_correlation", "identity"): + raise ValueError("target must be 'constant_correlation' or 'identity'") + super().__init__(fading_factor) + self.delta = delta + self.target = target + + def _to_matrix_array(self) -> np.ndarray: + S = self._raw_cov() + d = S.shape[0] + if self.target == "identity": + target = (np.trace(S) / d) * np.eye(d) + else: # constant_correlation + sd = np.sqrt(np.maximum(np.diag(S), _EPS)) + corr = S / np.outer(sd, sd) + off = corr[~np.eye(d, dtype=bool)] + rbar = float(np.mean(off)) if off.size else 0.0 + target = rbar * np.outer(sd, sd) + np.fill_diagonal(target, np.diag(S)) # preserve the variances + return (1.0 - self.delta) * S + self.delta * target + + +class EwaPrecision(_EWMatrix): + """Exponentially weighted precision (inverse covariance) matrix. + + The recency-weighted analogue of `EmpiricalPrecision`. It maintains the inverse of an + exponentially weighted second-moment matrix online via a forgetting-factor Sherman-Morrison + update (the recursive-least-squares trick), and applies the mean centering on read. It is + genuinely online: the per-step cost is O(d^2) and the matrix is never explicitly inverted. + + **When to use it.** Several methods need the *precision* matrix rather than the covariance: + the Mahalanobis distance (anomaly detection), the Gaussian log-likelihood, and the weights of a + Gaussian graphical model. Use this when those quantities must track a non-stationary stream, + where inverting a stale covariance would lag. Like `EmpiricalPrecision`, the result is not + guaranteed identical to inverting the matching covariance (there is a decaying identity prior), + but the difference shrinks as observations accumulate. Requires ``0 < fading_factor < 1``. + + Parameters + ---------- + fading_factor + Recency weight of the most recent observation. The effective memory is roughly + ``1 / fading_factor`` observations. + + Examples + -------- + + >>> from river import covariance, datasets + + >>> tickers = ["AAPL", "JPM", "XOM"] + >>> prec = covariance.EwaPrecision(fading_factor=0.02) + >>> for x, _ in datasets.SP500Stocks(): + ... prec.update({t: x[t] for t in tickers}) + >>> prec + AAPL JPM XOM + AAPL 0.676 -0.241 -0.169 + JPM -0.241 1.105 -0.498 + XOM -0.169 -0.498 0.934 + + Up to the decaying prior, this approximates the inverse of the matching `EwaCovariance`: + + >>> import numpy as np + >>> cov = covariance.EwaCovariance(fading_factor=0.02) + >>> for x, _ in datasets.SP500Stocks(): + ... cov.update({t: x[t] for t in tickers}) + >>> S = np.array([[cov[i, j] for j in tickers] for i in tickers]) + >>> P = np.array([[prec[i, j] for j in tickers] for i in tickers]) + >>> bool(np.allclose(P @ S, np.eye(3), atol=1e-6)) + True + + References + ---------- + [^1]: [Online Estimation of the Inverse Covariance Matrix - Markus Thill](https://markusthill.github.io/math/stats/ml/online-estimation-of-the-inverse-covariance-matrix/) + [^2]: [Recursive least squares filter](https://en.wikipedia.org/wiki/Recursive_least_squares_filter) + + """ + + def __init__(self, fading_factor: float = 0.5): + if not 0 < fading_factor < 1: + raise ValueError("fading_factor must be strictly between 0 and 1") + super().__init__(fading_factor) + self._Pm = np.zeros((0, 0), dtype=np.float64) # inverse of the EW second-moment matrix + + def _on_grow(self, old_dim: int, new_dim: int) -> None: + # New features start from an identity prior on the second-moment matrix. + Pm = np.eye(new_dim, dtype=np.float64) + Pm[:old_dim, :old_dim] = self._Pm + self._Pm = Pm + + def _learn_vector(self, v: np.ndarray) -> None: + f = self.fading_factor + fresh = self._fresh() + # Forgetting-factor Sherman-Morrison update of inv(M2), where M2 <- (1-f) M2 + f v vᵀ. + Pm = self._Pm / (1 - f) + Pv = Pm @ v + denom = 1.0 + f * float(v @ Pv) + Pm = Pm - f * np.outer(Pv, Pv) / denom + self._Pm = 0.5 * (Pm + Pm.T) + self._mean = self._blend_mean(v, fresh) + self._initialized[fresh] = True + self._n += 1 + self._matrix_cache = None + + def _to_matrix_array(self) -> np.ndarray: + # precision = inv(M2 - mean meanᵀ), via a rank-one Sherman-Morrison downdate of inv(M2). + Pm = self._Pm + m = self._mean + Pm_m = Pm @ m + denom = 1.0 - float(m @ Pm_m) + prec = Pm if denom <= _EPS else Pm + np.outer(Pm_m, Pm_m) / denom + return 0.5 * (prec + prec.T) diff --git a/river/covariance/test_ewa.py b/river/covariance/test_ewa.py new file mode 100644 index 0000000000..2dd51a1a37 --- /dev/null +++ b/river/covariance/test_ewa.py @@ -0,0 +1,322 @@ +from __future__ import annotations + +import numpy as np +import pandas as pd +import pytest + +from river import covariance, stats, stream + + +def _dense(cov): + """Materialize a SymmetricMatrix into a dense numpy array (public-API only).""" + names = sorted({i for i, _ in cov.matrix}) + return np.array([[cov[i, j] for j in names] for i in names], dtype=float) + + +def _ewa_reference(X, fading_factor): + """Independent exponentially weighted covariance, matching the stats.EWMean convention.""" + f = fading_factor + mean = M2 = None + for v in X: + v = np.asarray(v, dtype=float) + if mean is None: + mean, M2 = v.copy(), np.outer(v, v) + else: + mean = (1 - f) * mean + f * v + M2 = (1 - f) * M2 + f * np.outer(v, v) + return M2 - np.outer(mean, mean) + + +def _precision_reference(X, fading_factor): + """Independent EW precision: inverse of the EW second-moment matrix (identity prior).""" + f = fading_factor + d = X.shape[1] + M2 = np.eye(d) + mean = None + for v in X: + v = np.asarray(v, dtype=float) + M2 = (1 - f) * M2 + f * np.outer(v, v) + mean = v.copy() if mean is None else (1 - f) * mean + f * v + return np.linalg.inv(M2 - np.outer(mean, mean)) + + +@pytest.fixture +def returns(): + rng = np.random.default_rng(0) + true_cov = [[1.0, 0.5, 0.3], [0.5, 1.0, 0.4], [0.3, 0.4, 1.0]] + return rng.multivariate_normal(mean=[0, 0, 0], cov=true_cov, size=300) + + +# --------------------------------------------------------------------- EwaCovariance core + + +def test_ewa_matches_numpy_reference(returns): + cov = covariance.EwaCovariance(fading_factor=0.05) + for x, _ in stream.iter_array(returns): + cov.update(x) + np.testing.assert_allclose(_dense(cov), _ewa_reference(returns, 0.05)) + + +def test_ewa_diagonal_matches_ewvar_and_offdiag_ewcov(returns): + f = 0.05 + cov = covariance.EwaCovariance(fading_factor=f) + ewvars = [stats.EWVar(fading_factor=f) for _ in range(3)] + ewcov_01 = stats.EWCov(fading_factor=f) + for x, _ in stream.iter_array(returns): + cov.update(x) + for i in range(3): + ewvars[i].update(x[i]) + ewcov_01.update(x[0], x[1]) + + for i in range(3): + assert cov[i, i] == pytest.approx(ewvars[i].get()) + assert cov[0, 1] == pytest.approx(ewcov_01.get()) + + +@pytest.mark.parametrize( + "estimator", + [ + covariance.EwaCovariance, + covariance.EwaPrecision, + covariance.LedoitWolfCovariance, + covariance.OASCovariance, + covariance.ShrunkCovariance, + ], +) +def test_single_equals_minibatch(returns, estimator): + one_by_one = estimator() + for x, _ in stream.iter_array(returns): + one_by_one.update(x) + + batched = estimator() + batched.update_many(pd.DataFrame(returns, columns=[0, 1, 2])) + + np.testing.assert_allclose(_dense(one_by_one), _dense(batched)) + + +def test_minibatch_backend_agnostic(returns): + pl = pytest.importorskip("polars") + df = pd.DataFrame(returns, columns=["a", "b", "c"]) + + pandas_cov = covariance.EwaCovariance(fading_factor=0.05) + pandas_cov.update_many(df) + + polars_cov = covariance.EwaCovariance(fading_factor=0.05) + polars_cov.update_many(pl.from_pandas(df)) + + np.testing.assert_allclose(_dense(pandas_cov), _dense(polars_cov)) + + +@pytest.mark.parametrize( + "estimator", + [ + covariance.EmpiricalCovariance, + covariance.EmpiricalPrecision, + covariance.EwaCovariance, + covariance.EwaPrecision, + covariance.ShrunkCovariance, + ], +) +def test_empty_repr_does_not_crash(estimator): + assert repr(estimator()) == f"{estimator.__name__} (empty)" + + +def test_missing_feature_raises(): + cov = covariance.EwaCovariance() + cov.update({"a": 1.0, "b": 2.0}) + with pytest.raises(ValueError, match="missing feature"): + cov.update({"a": 1.0}) + + +# --------------------------------------------------------------------- EwaPrecision + + +def test_ewa_precision_matches_reference(returns): + prec = covariance.EwaPrecision(fading_factor=0.05) + for x, _ in stream.iter_array(returns): + prec.update(x) + np.testing.assert_allclose(_dense(prec), _precision_reference(returns, 0.05)) + + +def test_ewa_precision_inverts_covariance(): + # Enough samples that the decaying identity prior (~ (1 - f)^n) is numerically negligible. + rng = np.random.default_rng(2) + X = rng.multivariate_normal( + mean=[0, 0, 0], cov=[[1.0, 0.5, 0.3], [0.5, 1.0, 0.4], [0.3, 0.4, 1.0]], size=2000 + ) + f = 0.05 + cov = covariance.EwaCovariance(fading_factor=f) + prec = covariance.EwaPrecision(fading_factor=f) + for x, _ in stream.iter_array(X): + cov.update(x) + prec.update(x) + S, P = _dense(cov), _dense(prec) + np.testing.assert_allclose(P @ S, np.eye(S.shape[0]), atol=1e-9) + + +def test_ewa_precision_requires_strict_fading_factor(): + for bad in (0.0, 1.0): + with pytest.raises(ValueError, match="strictly between 0 and 1"): + covariance.EwaPrecision(fading_factor=bad) + + +# --------------------------------------------------------------------- shrinkage estimators + + +@pytest.mark.parametrize( + "estimator", + [ + covariance.LedoitWolfCovariance, + covariance.OASCovariance, + covariance.EwaPrecision, + lambda: covariance.ShrunkCovariance(target="identity", delta=0.2), + lambda: covariance.ShrunkCovariance(target="constant_correlation", delta=0.2), + ], +) +def test_matrix_is_symmetric(returns, estimator): + cov = estimator() + for x, _ in stream.iter_array(returns): + cov.update(x) + M = _dense(cov) + np.testing.assert_allclose(M, M.T) + + +@pytest.mark.parametrize( + "estimator", + [ + covariance.LedoitWolfCovariance, + covariance.OASCovariance, + lambda: covariance.ShrunkCovariance(target="identity", delta=0.2), + ], +) +def test_shrinkage_towards_identity_is_psd(returns, estimator): + cov = estimator() + for x, _ in stream.iter_array(returns): + cov.update(x) + eigvals = np.linalg.eigvalsh(_dense(cov)) + assert np.all(eigvals > -1e-9) + + +def test_shrunk_identity_matches_sklearn(returns): + from sklearn.covariance import shrunk_covariance + + f, delta = 0.05, 0.3 + raw = covariance.EwaCovariance(fading_factor=f) + shrunk = covariance.ShrunkCovariance(fading_factor=f, delta=delta, target="identity") + for x, _ in stream.iter_array(returns): + raw.update(x) + shrunk.update(x) + + expected = shrunk_covariance(_dense(raw), shrinkage=delta) + np.testing.assert_allclose(_dense(shrunk), expected) + + +def test_shrunk_delta_bounds(returns): + f = 0.05 + raw = covariance.EwaCovariance(fading_factor=f) + no_shrink = covariance.ShrunkCovariance(fading_factor=f, delta=0.0, target="identity") + full_shrink = covariance.ShrunkCovariance(fading_factor=f, delta=1.0, target="identity") + for x, _ in stream.iter_array(returns): + raw.update(x) + no_shrink.update(x) + full_shrink.update(x) + + S = _dense(raw) + np.testing.assert_allclose(_dense(no_shrink), S) + mu = np.trace(S) / S.shape[0] + np.testing.assert_allclose(_dense(full_shrink), mu * np.eye(S.shape[0]), atol=1e-9) + + +def test_ledoitwolf_is_convex_combination_towards_identity(returns): + f = 0.05 + raw = covariance.EwaCovariance(fading_factor=f) + lw = covariance.LedoitWolfCovariance(fading_factor=f) + for x, _ in stream.iter_array(returns): + raw.update(x) + lw.update(x) + + S, L = _dense(raw), _dense(lw) + mu = np.trace(S) / S.shape[0] + # Recover the intensity from an off-diagonal entry (the identity target is 0 there). + delta = 1 - L[0, 1] / S[0, 1] + assert 0.0 <= delta <= 1.0 + np.testing.assert_allclose(L, (1 - delta) * S + delta * mu * np.eye(S.shape[0]), atol=1e-9) + + +def test_oas_matches_formula(returns): + f = 0.05 + raw = covariance.EwaCovariance(fading_factor=f) + oas = covariance.OASCovariance(fading_factor=f) + for x, _ in stream.iter_array(returns): + raw.update(x) + oas.update(x) + + S = _dense(raw) + d = S.shape[0] + n = max(1.0 / f, 2.0) + tr, tr2 = np.trace(S), np.trace(S @ S) + mu = tr / d + num = (1 - 2 / d) * tr2 + tr * tr + den = (n + 1 - 2 / d) * (tr2 - tr * tr / d) + rho = 1.0 if den <= 0 else min(max(num / den, 0.0), 1.0) + np.testing.assert_allclose(_dense(oas), (1 - rho) * S + rho * mu * np.eye(d)) + + +def test_shrinkage_improves_conditioning(): + # Many variables, short effective memory: the raw covariance is ill-conditioned. + rng = np.random.default_rng(1) + true_cov = 0.7 * np.ones((12, 12)) + 0.3 * np.eye(12) + X = rng.multivariate_normal(mean=np.zeros(12), cov=true_cov, size=200) + + raw = covariance.EwaCovariance(fading_factor=0.05) + lw = covariance.LedoitWolfCovariance(fading_factor=0.05) + oas = covariance.OASCovariance(fading_factor=0.05) + for x, _ in stream.iter_array(X): + raw.update(x) + lw.update(x) + oas.update(x) + + raw_cond = np.linalg.cond(_dense(raw)) + assert np.linalg.cond(_dense(lw)) < raw_cond + assert np.linalg.cond(_dense(oas)) < raw_cond + + +@pytest.mark.parametrize( + "estimator", + [ + covariance.EwaCovariance, + covariance.EwaPrecision, + covariance.LedoitWolfCovariance, + covariance.OASCovariance, + covariance.ShrunkCovariance, + ], +) +def test_pickle_round_trip(returns, estimator): + import pickle + + cov = estimator() + for x, _ in stream.iter_array(returns): + cov.update(x) + restored = pickle.loads(pickle.dumps(cov)) + np.testing.assert_allclose(_dense(restored), _dense(cov)) + + +# --------------------------------------------------------------------- stats.EWCov + + +def test_ewcov_matches_manual(): + f = 0.3 + x = [1.0, 3.0, 5.0, 4.0, 6.0] + y = [2.0, 4.0, 3.0, 6.0, 5.0] + ewcov = stats.EWCov(fading_factor=f) + + mx = my = mxy = None + for xi, yi in zip(x, y): + ewcov.update(xi, yi) + if mx is None: + mx, my, mxy = xi, yi, xi * yi + else: + mx = (1 - f) * mx + f * xi + my = (1 - f) * my + f * yi + mxy = (1 - f) * mxy + f * xi * yi + assert ewcov.get() == pytest.approx(mxy - mx * my) diff --git a/river/datasets/__init__.py b/river/datasets/__init__.py index c6c7f83a36..118a5ff4d2 100644 --- a/river/datasets/__init__.py +++ b/river/datasets/__init__.py @@ -32,6 +32,7 @@ from .sms_spam import SMSSpam from .smtp import SMTP from .solar_flare import SolarFlare +from .sp500 import SP500Stocks from .taxis import Taxis from .trec07 import TREC07 from .trump_approval import TrumpApproval @@ -63,6 +64,7 @@ "SMSSpam", "SMTP", "SolarFlare", + "SP500Stocks", "synth", "Taxis", "TREC07", diff --git a/river/datasets/sp500.csv.gz b/river/datasets/sp500.csv.gz new file mode 100644 index 0000000000000000000000000000000000000000..b769a2c3bc23780620dfc89c67ee8d5f62fc9229 GIT binary patch literal 59118 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 from river import stats + + >>> x = [1, 3, 5, 4] + >>> y = [2, 4, 3, 6] + + >>> ewcov = stats.EWCov(fading_factor=0.5) + >>> for xi, yi in zip(x, y): + ... ewcov.update(xi, yi) + ... print(ewcov.get()) + 0.0 + 1.0 + 0.5 + 0.625 + + References + ---------- + [^1]: [Finch, T., 2009. Incremental calculation of weighted mean and variance. University of Cambridge, 4(11-5), pp.41-42.](https://fanf2.user.srcf.net/hermes/doc/antiforgery/stats.pdf) + [^2]: [Exponential Moving Average on Streaming Data](https://dev.to/nestedsoftware/exponential-moving-average-on-streaming-data-4hhl) + + """ + + def __init__(self, fading_factor=0.5): + if not 0 <= fading_factor <= 1: + raise ValueError("fading_factor is not comprised between 0 and 1") + self.fading_factor = fading_factor + self._mean_x = stats.EWMean(fading_factor) + self._mean_y = stats.EWMean(fading_factor) + self._mean_xy = stats.EWMean(fading_factor) + + @property + def name(self): + return f"ewcov_{self.fading_factor}" + + def update(self, x, y): + self._mean_x.update(x) + self._mean_y.update(y) + self._mean_xy.update(x * y) + + def get(self): + return self._mean_xy.get() - self._mean_x.get() * self._mean_y.get() From 2e2d62ae28f29d5af94977e985d7c5149a0ef134 Mon Sep 17 00:00:00 2001 From: Max Halford Date: Thu, 25 Jun 2026 20:14:43 +0200 Subject: [PATCH 2/4] test(covariance): use independent references for covariance tests Replace the circular EWCov test (which re-implemented the estimator's own E[xy]-E[x]E[y] recursion) with a comparison against pandas' ewm().cov(), and add a test comparing EmpiricalCovariance against sklearn's batch estimator. Co-Authored-By: Claude Opus 4.8 (1M context) --- river/covariance/test_ewa.py | 42 +++++++++++++++++++++++++++--------- 1 file changed, 32 insertions(+), 10 deletions(-) diff --git a/river/covariance/test_ewa.py b/river/covariance/test_ewa.py index 2dd51a1a37..4e0dba4b5d 100644 --- a/river/covariance/test_ewa.py +++ b/river/covariance/test_ewa.py @@ -10,7 +10,12 @@ def _dense(cov): """Materialize a SymmetricMatrix into a dense numpy array (public-API only).""" names = sorted({i for i, _ in cov.matrix}) - return np.array([[cov[i, j] for j in names] for i in names], dtype=float) + + def value(entry): + # EmpiricalCovariance stores stats.Cov/Var objects; EWA stores plain floats. + return entry.get() if isinstance(entry, stats.base.Statistic) else entry + + return np.array([[value(cov[i, j]) for j in names] for i in names], dtype=float) def _ewa_reference(X, fading_factor): @@ -301,22 +306,39 @@ def test_pickle_round_trip(returns, estimator): np.testing.assert_allclose(_dense(restored), _dense(cov)) +# --------------------------------------------------------------------- EmpiricalCovariance + + +def test_empirical_covariance_matches_sklearn(returns): + # sklearn's EmpiricalCovariance is the maximum-likelihood estimate, i.e. ddof=0. + sklearn_cov = pytest.importorskip("sklearn.covariance") + + cov = covariance.EmpiricalCovariance(ddof=0) + for x, _ in stream.iter_array(returns): + cov.update(x) + + expected = sklearn_cov.EmpiricalCovariance().fit(returns).covariance_ + np.testing.assert_allclose(_dense(cov), expected) + + # --------------------------------------------------------------------- stats.EWCov -def test_ewcov_matches_manual(): +def test_ewcov_matches_pandas(): + # pandas computes the exponentially weighted covariance with its own routine rather than + # via the E[xy] - E[x]E[y] identity used by stats.EWCov, so this is an independent check. + # adjust=False matches the recursive stats.EWMean convention; bias=True matches the + # population (uncorrected) covariance that the identity yields. f = 0.3 x = [1.0, 3.0, 5.0, 4.0, 6.0] y = [2.0, 4.0, 3.0, 6.0, 5.0] ewcov = stats.EWCov(fading_factor=f) - mx = my = mxy = None + values = [] for xi, yi in zip(x, y): ewcov.update(xi, yi) - if mx is None: - mx, my, mxy = xi, yi, xi * yi - else: - mx = (1 - f) * mx + f * xi - my = (1 - f) * my + f * yi - mxy = (1 - f) * mxy + f * xi * yi - assert ewcov.get() == pytest.approx(mxy - mx * my) + values.append(ewcov.get()) + + df = pd.DataFrame({"x": x, "y": y}) + expected = df["x"].ewm(alpha=f, adjust=False).cov(df["y"], bias=True) + np.testing.assert_allclose(values, expected.to_numpy()) From 6735ea6ca854ec7f51b58ed5699581285a37edbf Mon Sep 17 00:00:00 2001 From: Max Halford Date: Thu, 25 Jun 2026 20:17:17 +0200 Subject: [PATCH 3/4] test(covariance): simplify new covariance tests Drop pytest.importorskip in favour of a plain inline sklearn import (matching the existing sklearn test), extract the _dense value helper to module level, and trim the EWCov comment. Co-Authored-By: Claude Opus 4.8 (1M context) --- river/covariance/test_ewa.py | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/river/covariance/test_ewa.py b/river/covariance/test_ewa.py index 4e0dba4b5d..6d84114d34 100644 --- a/river/covariance/test_ewa.py +++ b/river/covariance/test_ewa.py @@ -7,15 +7,15 @@ from river import covariance, stats, stream +def _value(entry): + # EmpiricalCovariance stores stats.Cov/Var objects; EWA stores plain floats. + return entry.get() if isinstance(entry, stats.base.Statistic) else entry + + def _dense(cov): """Materialize a SymmetricMatrix into a dense numpy array (public-API only).""" names = sorted({i for i, _ in cov.matrix}) - - def value(entry): - # EmpiricalCovariance stores stats.Cov/Var objects; EWA stores plain floats. - return entry.get() if isinstance(entry, stats.base.Statistic) else entry - - return np.array([[value(cov[i, j]) for j in names] for i in names], dtype=float) + return np.array([[_value(cov[i, j]) for j in names] for i in names], dtype=float) def _ewa_reference(X, fading_factor): @@ -310,14 +310,14 @@ def test_pickle_round_trip(returns, estimator): def test_empirical_covariance_matches_sklearn(returns): - # sklearn's EmpiricalCovariance is the maximum-likelihood estimate, i.e. ddof=0. - sklearn_cov = pytest.importorskip("sklearn.covariance") + from sklearn.covariance import EmpiricalCovariance as SklearnEmpiricalCovariance + # sklearn uses the maximum-likelihood estimate, i.e. ddof=0. cov = covariance.EmpiricalCovariance(ddof=0) for x, _ in stream.iter_array(returns): cov.update(x) - expected = sklearn_cov.EmpiricalCovariance().fit(returns).covariance_ + expected = SklearnEmpiricalCovariance().fit(returns).covariance_ np.testing.assert_allclose(_dense(cov), expected) @@ -325,10 +325,8 @@ def test_empirical_covariance_matches_sklearn(returns): def test_ewcov_matches_pandas(): - # pandas computes the exponentially weighted covariance with its own routine rather than - # via the E[xy] - E[x]E[y] identity used by stats.EWCov, so this is an independent check. - # adjust=False matches the recursive stats.EWMean convention; bias=True matches the - # population (uncorrected) covariance that the identity yields. + # pandas computes the EW covariance independently of the E[xy] - E[x]E[y] identity used by + # stats.EWCov. adjust=False / bias=True match its recursive, uncorrected convention. f = 0.3 x = [1.0, 3.0, 5.0, 4.0, 6.0] y = [2.0, 4.0, 3.0, 6.0, 5.0] From 1dc9d63642c2d8f4561ef9e9e4f424ddad107875 Mon Sep 17 00:00:00 2001 From: Max Halford Date: Fri, 26 Jun 2026 14:03:36 +0200 Subject: [PATCH 4/4] feat(covariance): make EmpiricalCovariance/EmpiricalPrecision update_many narwhals-native Migrate the empirical estimators' `update_many` off the hard-coded pandas path (`.values`/`.columns`) to the `utils.dataframe` narwhals boundary helpers, matching the new EWA/shrinkage estimators and the rest of the #1919 migration. Any narwhals-supported eager dataframe (pandas, polars, pyarrow, ...) now flows through; the pandas path is byte-for-byte unchanged. Adds multi-backend tests via the `frame_backend` fixture. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/releases/unreleased.md | 1 + river/covariance/emp.py | 29 +++++++++++++++++++---------- river/covariance/test_emp.py | 31 +++++++++++++++++++++++++++++++ 3 files changed, 51 insertions(+), 10 deletions(-) diff --git a/docs/releases/unreleased.md b/docs/releases/unreleased.md index 48fcc81c3d..afbd846ee3 100644 --- a/docs/releases/unreleased.md +++ b/docs/releases/unreleased.md @@ -17,6 +17,7 @@ - Added `EwaCovariance`, `LedoitWolfCovariance`, `OASCovariance`, and `ShrunkCovariance`: online covariance estimators for non-stationary streams (exponentially weighted, recency-biased) and high-dimensional / few-sample regimes (shrinkage towards a well-conditioned target). They are dict-native like `EmpiricalCovariance` and support mini-batches via `update_many` on any [narwhals](https://github.com/narwhals-dev/narwhals)-supported eager backend. - Added `EwaPrecision`, an exponentially weighted precision (inverse covariance) matrix maintained online via a forgetting-factor Sherman-Morrison update. The recency-weighted counterpart of `EmpiricalPrecision`, useful for tracking Mahalanobis distances and Gaussian likelihoods on non-stationary streams. +- `EmpiricalCovariance.update_many` and `EmpiricalPrecision.update_many` now accept any [narwhals](https://github.com/narwhals-dev/narwhals)-supported eager dataframe (pandas, polars, pyarrow, ...) instead of pandas only. Outputs are unchanged for the pandas path. - Added weighted sample support to `EmpiricalCovariance.update` and `EmpiricalCovariance.revert` by accepting an optional `w` parameter and propagating it to the underlying `stats.Cov` and `stats.Var` statistics. - Sped up `EmpiricalCovariance.update`/`revert` (~40% faster at 30 features) by caching the sorted feature list and pair iteration in the hot path. No semantic change. - Restructured `EmpiricalPrecision` around NumPy-backed dense state, removing the per-update dict ↔ numpy marshalling. ~7× faster on 2000 × 20 sample streams. diff --git a/river/covariance/emp.py b/river/covariance/emp.py index 05160b6b44..13c9bc2a51 100644 --- a/river/covariance/emp.py +++ b/river/covariance/emp.py @@ -9,7 +9,7 @@ from river import stats, utils if typing.TYPE_CHECKING: - import pandas as pd + from narwhals.stable.v2.typing import IntoDataFrame class SymmetricMatrix(abc.ABC): @@ -179,9 +179,12 @@ def revert(self, x: dict, w: float = 1.0): for i in keys: cov_dict[i, i].revert(x[i], w) - def update_many(self, X: pd.DataFrame): + def update_many(self, X: IntoDataFrame): """Update with a dataframe of samples. + Any [narwhals](https://github.com/narwhals-dev/narwhals)-compatible eager dataframe + (pandas, polars, pyarrow, ...) is accepted. + Parameters ---------- X @@ -189,15 +192,17 @@ def update_many(self, X: pd.DataFrame): """ - X_arr = X.values + frame = utils.dataframe.into_frame(X) + columns = list(frame.columns) + X_arr = utils.dataframe.to_numpy(frame) mean_arr = X_arr.mean(axis=0) cov_arr = np.cov(X_arr.T, ddof=self.ddof) - n = len(X) - mean = dict(zip(X.columns, mean_arr)) + n = len(frame) + mean = dict(zip(columns, mean_arr)) cov = { (i, j): cov_arr[r, c] - for (r, i), (c, j) in itertools.combinations_with_replacement(enumerate(X.columns), r=2) + for (r, i), (c, j) in itertools.combinations_with_replacement(enumerate(columns), r=2) } self._update_from_state(n=n, mean=mean, cov=cov) @@ -409,17 +414,21 @@ def update(self, x): self._w_arr[ids] = w self._inv_cov_mat[ix] = 0.5 * (block + block.T) - def update_many(self, X: pd.DataFrame): + def update_many(self, X: IntoDataFrame): """Update with a dataframe of samples. + Any [narwhals](https://github.com/narwhals-dev/narwhals)-compatible eager dataframe + (pandas, polars, pyarrow, ...) is accepted. + Parameters ---------- X A dataframe of samples. """ - ids = self._ensure_features(X.columns) - X_arr = np.asarray(X.values, dtype=np.float64) + frame = utils.dataframe.into_frame(X) + ids = self._ensure_features(frame.columns) + X_arr = utils.dataframe.to_numpy(frame) loc = self._loc_arr[ids].copy() w = self._w_arr[ids].copy() @@ -427,7 +436,7 @@ def update_many(self, X: pd.DataFrame): inv_cov = np.asfortranarray(self._inv_cov_mat[ix]) / np.maximum(w, 1) # update formulas - n_batch = len(X) + n_batch = len(frame) diff = X_arr - loc loc = (w * loc + n_batch * X_arr.mean(axis=0)) / (w + n_batch) w += n_batch diff --git a/river/covariance/test_emp.py b/river/covariance/test_emp.py index be826625e4..fd34dba7ce 100644 --- a/river/covariance/test_emp.py +++ b/river/covariance/test_emp.py @@ -164,6 +164,37 @@ def test_covariance_update_many_sampled(): assert math.isclose(cov[i, j].get(), pd_cov.loc[i, j]) +def test_covariance_update_many_backend_agnostic(frame_backend): + """`update_many` yields identical covariances across every dataframe backend.""" + np.random.seed(0) + data = {col: np.random.random(40).tolist() for col in ["a", "b", "c"]} + + reference = covariance.EmpiricalCovariance() + reference.update_many(pd.DataFrame(data)) + + cov = covariance.EmpiricalCovariance() + cov.update_many(frame_backend.frame(data)) + + assert cov.matrix.keys() == reference.matrix.keys() + for key in reference.matrix: + assert math.isclose(cov[key].get(), reference[key].get(), rel_tol=1e-9, abs_tol=1e-12) + + +def test_precision_update_many_backend_agnostic(frame_backend): + """`update_many` yields identical precisions across every dataframe backend.""" + np.random.seed(0) + data = {col: np.random.random(60).tolist() for col in ["a", "b", "c"]} + + reference = covariance.EmpiricalPrecision() + reference.update_many(pd.DataFrame(data)) + + prec = covariance.EmpiricalPrecision() + prec.update_many(frame_backend.frame(data)) + + for i, j in reference.matrix: + assert math.isclose(prec[i, j], reference[i, j], rel_tol=1e-9, abs_tol=1e-12) + + def test_precision_update_shuffled(): C1 = covariance.EmpiricalPrecision() C2 = covariance.EmpiricalPrecision()