Covariance-Conditioned Neural Rollouts / mvp.py

Mechanism confirmed, baseline not beaten

Raw ⬇ ZIP
  1import json
  2from pathlib import Path
  3import numpy as np
  4
  5SEED = 988
  6
  7def fit_joint(w, y, ridge=1e-6):
  8    mw, my = w.mean(0), y.mean(0)
  9    wc, yc = w - mw, y - my
 10    sww = wc.T @ wc / (len(w) - 1)
 11    syw = yc.T @ wc / (len(w) - 1)
 12    syy = yc.T @ yc / (len(w) - 1)
 13    gain = np.linalg.solve(sww + ridge * np.eye(w.shape[1]), syw.T).T
 14    cov = syy - gain @ syw.T
 15    return mw, my, gain, (cov + cov.T) / 2
 16
 17def condition(model, w):
 18    mw, my, gain, cov = model
 19    return my + (w - mw) @ gain.T, cov
 20
 21def nll(errors, cov, jitter=1e-8):
 22    c = (cov + cov.T) / 2 + jitter * np.eye(cov.shape[0])
 23    sign, ld = np.linalg.slogdet(c)
 24    if sign <= 0:
 25        return float("inf")
 26    q = np.mean(np.sum(errors * np.linalg.solve(c, errors.T).T, axis=1))
 27    return float(.5 * (q + ld + errors.shape[1] * np.log(2 * np.pi)))
 28
 29def coverage(errors, cov, alpha=.95):
 30    c = (cov + cov.T) / 2 + 1e-8 * np.eye(len(cov))
 31    q = np.sum(errors * np.linalg.solve(c, errors.T).T, axis=1)
 32    # chi-square(10) 95% quantile, avoiding a scipy dependency in the MVP
 33    threshold = 18.307038053275146 if len(cov) == 10 else np.quantile(q, alpha)
 34    return float(np.mean(q <= threshold)), float(np.mean(q))
 35
 36def ar(n, total, rho, sigma, rng):
 37    x = np.empty((n, total))
 38    x[:, 0] = rng.normal(size=n) * sigma / np.sqrt(1 - rho*rho)
 39    for t in range(1, total):
 40        x[:, t] = rho * x[:, t-1] + sigma * rng.normal(size=n)
 41    return x
 42
 43def main():
 44    rng = np.random.default_rng(SEED)
 45    out = {}
 46
 47    # Core algebra: Schur complement of a positive definite joint covariance.
 48    a = rng.normal(size=(10, 10))
 49    joint = a @ a.T + .2 * np.eye(10)
 50    sww, swy = joint[:4, :4], joint[:4, 4:]
 51    syw, syy = joint[4:, :4], joint[4:, 4:]
 52    schur = syy - syw @ np.linalg.solve(sww, swy)
 53    out["math_check"] = {
 54        "min_joint_eigenvalue": float(np.linalg.eigvalsh(joint).min()),
 55        "min_schur_eigenvalue": float(np.linalg.eigvalsh(schur).min()),
 56        "schur_trace": float(np.trace(schur)),
 57        "psd_verified": bool(np.linalg.eigvalsh(schur).min() > -1e-10),
 58    }
 59
 60    # Same process and windows for both predictors.
 61    rho, sigma, H = .85, 1.0, 10
 62    ntrain, ntest = 16000, 6000
 63    train = ar(ntrain, 20 + H, rho, sigma, rng)
 64    test = ar(ntest, 20 + H, rho, sigma, rng)
 65    wtr, ytr = train[:, 19:20], train[:, 20:]
 66    wte, yte = test[:, 19:20], test[:, 20:]
 67
 68    # Standard rollout: estimate one-step AR coefficient, recursively apply it.
 69    x0 = wtr[:, 0]
 70    rho_hat = float((x0 @ ytr[:, 0]) / (x0 @ x0))
 71    powers = rho_hat ** np.arange(1, H + 1)
 72    base_mean = wte * powers[None, :]
 73    # Innovation variance accumulated by recursive rollout; diagonal baseline.
 74    marginal_var = sigma*sigma * np.array([(1-rho_hat**(2*k))/(1-rho_hat**2) for k in range(1, H+1)])
 75    base_cov = np.diag(marginal_var)
 76
 77    # Idea: learn all future coordinates jointly and condition their covariance on w.
 78    joint_model = fit_joint(wtr, ytr, ridge=1e-6)
 79    idea_mean, idea_cov = condition(joint_model, wte)
 80    base_err, idea_err = yte - base_mean, yte - idea_mean
 81    out["rollout"] = {
 82        "rho_hat": rho_hat,
 83        "baseline_recursive": {
 84            "mse": float(np.mean(base_err**2)),
 85            "nll": nll(base_err, base_cov),
 86            "coverage95": coverage(base_err, base_cov)[0],
 87        },
 88        "covariance_conditioned": {
 89            "mse": float(np.mean(idea_err**2)),
 90            "nll": nll(idea_err, idea_cov),
 91            "coverage95": coverage(idea_err, idea_cov)[0],
 92            "conditional_trace": float(np.trace(idea_cov)),
 93            "min_cov_eigenvalue": float(np.linalg.eigvalsh(idea_cov).min()),
 94        },
 95    }
 96
 97    # Nested-prefix check at a fixed prediction origin. AR theory predicts p=1 helps;
 98    # older history is redundant, so this explicitly tests the limitation.
 99    prefix_rows = []
100    target = train[:, 20:30]
101    target_test = test[:, 20:30]
102    for p in [0, 1, 2, 4, 8, 12]:
103        if p == 0:
104            mean = target.mean(0)
105            cov = np.diag(np.var(target, axis=0, ddof=1))
106            pred = np.broadcast_to(mean, target_test.shape)
107        else:
108            model = fit_joint(train[:, 20-p:20], target, ridge=1e-6)
109            pred, cov = condition(model, test[:, 20-p:20])
110        prefix_rows.append({
111            "prefix": p,
112            "conditional_trace": float(np.trace(cov)),
113            "test_mse": float(np.mean((target_test-pred)**2)),
114            "min_eigenvalue": float(np.linalg.eigvalsh(cov).min()),
115        })
116    out["prefix_sweep"] = prefix_rows
117    Path("mvp_results.json").write_text(json.dumps(out, indent=2))
118    print(json.dumps(out, indent=2))
119
120if __name__ == "__main__":
121    main()