Skip to content

LedoitWolf outlier cap is disabled for the first post-burn observation #94

Description

@microprediction

Summary

At current default-branch commit 4b26c0c, LedoitWolfCovariance deliberately caps each fourth-moment dispersion observation relative to the running mean so that one fat-tailed draw cannot drive shrinkage to 1 and erase all covariance structure. But pi_bar remains exactly zero throughout empirical burn-in, so the first exponentially weighted observation cannot be capped.

A sufficiently large one-coordinate move on that exact observation makes the shrinkage intensity clip to 1 and returns a scaled identity. The identical move one ordinary tick later is capped and retains essentially all cross-sectional structure. This creates a sharp startup/restart boundary in the estimator's advertised heavy-tail protection.

Relevant implementation

  • The state initializes pi_bar to zero: precise/ledoitwolf.py:33-36.
  • Every burn-in update copies that same value forward unchanged: lines 38-42.
  • The burn length is about 1/r, so it is 20 observations at the default r=.05: precise/_state.py:46-59.
  • The outlier cap only runs when pi_bar > 0; the comments explicitly say it prevents one fat tail from saturating the intensity: precise/ledoitwolf.py:43-63.
  • The public covariance then maps pi_bar into shrinkage intensity, clipping at 1: lines 65-75.

Why the first post-burn shock saturates

For a p-dimensional burn-in covariance near the identity and a large next observation M e1:

q ~ M^4 / p
pi_bar_new = r q
b2 = pi_bar_new r ~ r^2 M^4 / p
d2 ~ r^2 M^4 (p-1) / p^2
b2 / d2 ~ p / (p-1) > 1

So the intensity clips to 1 in the large-shock limit. This is a consequence of starting the dispersion mean from zero, not ordinary finite-sample noise.

Deterministic public-API reproduction

The example is in standardized return units; by scale equivariance, 10 can equally mean a 10% move against a 1% volatility scale.

import numpy as np
from precise import LedoitWolfCovariance

r = .05
p = 5
truth = np.array([
    [1, .7, .4, .2, .1],
    [.7, 1, .3, .2, .1],
    [.4, .3, 1, .4, .2],
    [.2, .2, .4, 1, .5],
    [.1, .1, .2, .5, 1],
])
burn = np.random.default_rng(20260925).multivariate_normal(
    np.zeros(p), truth, size=20
)

def run(seed_first):
    est = LedoitWolfCovariance(r=r).fit(burn)
    print("after burn:", est.n_samples_, est.get_state()["n_burn"],
          est.get_state()["pi_bar"])

    if seed_first:
        est.partial_fit([.2, -.1, .3, -.2, .1])

    before = est.get_state()["pi_bar"]
    est.partial_fit([10., 0., 0., 0., 0.])

    s = est.get_state()
    S = np.asarray(s["cov"])
    mu = np.trace(S) / p
    d2 = np.sum((S - mu * np.eye(p)) ** 2) / p
    intensity = min(s["pi_bar"] * r, d2) / d2

    C = est.covariance_
    Cmu = np.trace(C) / p
    retained = (
        np.linalg.norm(C - Cmu * np.eye(p))
        / np.linalg.norm(S - mu * np.eye(p))
    )
    print("seed_first:", seed_first)
    print("pi_bar before/after:", before, s["pi_bar"])
    print("intensity:", intensity)
    print("structure retained:", retained)

run(False)
run(True)

Observed:

after burn: 20 20 0.0
seed_first: False
pi_bar before/after: 0.0 97.63319897019574
intensity: 0.9528946548733156
structure retained: 0.04710534512668445

after burn: 20 20 0.0
seed_first: True
pi_bar before/after: 0.08830153237245382 0.12803722194005804
intensity: 0.0012793832325897782
structure retained: 0.99872061676741

With a 20σ move in the same reproduction, the observation immediately after burn-in gives intensity exactly 1, structure retained 0, and condition number 1: the public covariance is a scaled identity. One ordinary seed observation before the identical shock gives intensity 9.75e-5 and retains 99.99% of the structure.

Impact

This is most relevant when an online equity model is warmed up and then placed into service, or restarted from a batch warm-up. If the first live return is an earnings gap, overnight shock, newly active security, or crisis move, the estimator's explicit outlier defense is absent at exactly that boundary. Correlations are pulled toward zero and all variances toward their cross-sectional mean, affecting the inherited precision, Mahalanobis, score, and any allocation using the covariance.

LedoitWolfCovariance is registered as a public estimator and is also documented as a correlation component for ConditionalCovariance: precise/conditional.py:91-103.

Test gap

The general correctness test runs 4,000 Gaussian observations and checks only conditioning and nonnegative pi_bar: tests/test_correctness.py:88-102.

The dedicated heavy-tail regression runs 3,000 observations and evaluates only the final estimate: tests/test_correctness.py:372-403. It validates the cap after pi_bar has been seeded but cannot exercise the first post-burn transition.

Suggested direction

Give the cap a scale-consistent positive dispersion baseline before the first exponential update. Possibilities include tracking an appropriate q statistic during empirical burn-in, or deriving a robust seed from the burn-in sample at the transition. An arbitrary absolute epsilon would create a unit-dependence problem and should be avoided.

Add a regression test with a large observation exactly at n_burn + 1, plus adjacent-position tests showing that protection does not depend discontinuously on whether the shock lands one tick earlier or later.

Uncertainty

The failure and timing discontinuity above are verified. The exact statistically principled way to estimate the weighted Ledoit-Wolf fourth-moment quantity during an empirical-covariance burn-in is a design choice; simply averaging q under a moving empirical covariance may not be the preferred derivation. This report also does not claim that the raw EWA covariance itself is outlier-robust—the narrower contract is the code's stated promise that the q cap prevents one fat tail from erasing all estimated structure.

I searched current issues and all open pull requests for Ledoit-Wolf, pi_bar, burn-in, heavy tails, shrinkage collapse, and outlier-cap behavior; no existing report or change covers this boundary defect.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions