Buckets:
| """Claim 4 (Theorem 4.3, double robustness). | |
| "The loss difference between imputed feature distributions decays at a compound | |
| rate O_P(a_n b_n) even when both the predictive model and the sampler have | |
| estimation error." | |
| Theorem 4.3 (verbatim structure): for j in H0, t -> l(t, y) is C^2 with bounded | |
| derivatives, m_hat differentiable in coordinate j with | |
| a_n := d_{x^j} m_hat(X^{-j}, t), and nu_hat - nu = O_P(b_n). Then | |
| l(m_hat(X~'), y) - l(m_hat(X~), y) = O_P(a_n b_n). | |
| Here X~ uses the ORACLE nu and X~' the ESTIMATED nu_hat, with the SAME residual | |
| index, so the difference isolates the imputer error. | |
| Independent tests: | |
| 1. FIGURE 3 reproduction. y = 0.8X1 + 0.6X2 + 0.4X3 + 0.2X4 + sin(X1) + eps, | |
| eps ~ N(0, 0.5), n = 2000, X ~ N(0, Sigma), Sigma_ij = 0.5^|i-j|, nu_hat | |
| linear, for the null coordinate j = 0. Blue: the semi-knockoff statistic | |
| l(m(X~'_1),y) - l(m(X~'_2),y). Orange: l(m(X~'_1),y) - l(m(X~_1),y). | |
| Theorem 4.3 => orange is far more concentrated than blue. Random Forest, | |
| Neural Network, Gradient Boosting. | |
| 2. COMPOUND-RATE FIT. Sweep n and measure, separately, | |
| a_n = the model's empirical sensitivity to coordinate j | |
| (finite-difference d m_hat / d x^j), | |
| b_n = ||nu_hat - nu||_2 / sqrt(n), | |
| D_n = |mean_i l(m_hat(X~'_i), y_i) - l(m_hat(X~_i), y_i)|. | |
| Fit log D_n = c + s log n and compare s with the exponent of a_n b_n | |
| measured on the same runs. A single-robustness (linear, i.e. O_P(b_n)) | |
| decay would give s ~ -1/2; the compound rate gives s ~ -1. | |
| 3. LINEAR-MODEL SPECIALISATION (Remark 4.4): m_hat(X) = beta_hat' X so | |
| a_n = beta_hat^j which is O_P(n^{-1/2}) under the null, and nu_hat is | |
| parametric so b_n = O_P(n^{-1/2}) -> D_n should be O_P(1/n). | |
| 4. BOUNDARY PROBE. Break the null: for an IMPORTANT coordinate j, a_n is | |
| O(1) rather than O_P(n^{-1/2}), so only the single rate b_n should survive | |
| (s ~ -1/2). This is the discriminating prediction of Theorem 4.3. | |
| """ | |
| from __future__ import annotations | |
| import json | |
| import os | |
| import sys | |
| import time | |
| import numpy as np | |
| from joblib import Parallel, delayed | |
| from sklearn.ensemble import GradientBoostingRegressor, RandomForestRegressor | |
| from sklearn.linear_model import LinearRegression, Ridge | |
| from sklearn.neural_network import MLPRegressor | |
| sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)))) | |
| from semiknockoffs import ar1_cov, gaussian_nu, sq_loss # noqa: E402 | |
| OUT = os.path.join( | |
| os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "outputs" | |
| ) | |
| SEED0 = 20260725 | |
| def _gen(rng, n, p=10, rho=0.5): | |
| """Figure 3 data-generating process.""" | |
| Sigma = ar1_cov(p, rho) | |
| L = np.linalg.cholesky(Sigma) | |
| X = rng.standard_normal((n, p)) @ L.T | |
| y = ( | |
| 0.8 * X[:, 1] | |
| + 0.6 * X[:, 2] | |
| + 0.4 * X[:, 3] | |
| + 0.2 * X[:, 4] | |
| + np.sin(X[:, 1]) | |
| + 0.5 * rng.standard_normal(n) | |
| ) | |
| return X, y, Sigma # coordinate 0 is null | |
| def _model(kind, X, y, seed): | |
| if kind == "rf": | |
| return RandomForestRegressor(n_estimators=100, random_state=seed, n_jobs=1).fit( | |
| X, y | |
| ) | |
| if kind == "nn": | |
| return MLPRegressor( | |
| hidden_layer_sizes=(64, 64), max_iter=800, random_state=seed | |
| ).fit(X, y) | |
| if kind == "gb": | |
| return GradientBoostingRegressor(random_state=seed).fit(X, y) | |
| if kind == "linear": | |
| return LinearRegression().fit(X, y) | |
| raise ValueError(kind) | |
| # ------------------------------------------------------------------ Figure 3 | |
| def _fig3_rep(rep, kind, n=2000, p=10, j=0): | |
| rng = np.random.default_rng(SEED0 + 23_000_000 + 101 * rep) | |
| X, y, Sigma = _gen(rng, n, p) | |
| m = _model(kind, X, y, rep) | |
| cols = [k for k in range(p) if k != j] | |
| nu = gaussian_nu(X, j, Sigma) # ORACLE nu | |
| nu_h = Ridge(alpha=1.0).fit(X[:, cols], X[:, j]).predict(X[:, cols]) # ESTIMATED | |
| e_or = X[:, j] - nu | |
| e_es = X[:, j] - nu_h | |
| pi1 = rng.permutation(n) | |
| pi2 = rng.permutation(n) | |
| def build(base, eps, perm): | |
| Z = X.copy() | |
| Z[:, j] = base + eps[perm] | |
| return Z | |
| L1p = sq_loss(m.predict(build(nu_h, e_es, pi1)), y) # X~'_1 (estimated) | |
| L2p = sq_loss(m.predict(build(nu_h, e_es, pi2)), y) # X~'_2 (estimated) | |
| L1o = sq_loss(m.predict(build(nu, e_or, pi1)), y) # X~_1 (oracle) | |
| blue = L1p - L2p # the semi-knockoff statistic itself | |
| orange = L1p - L1o # theoretical vs estimated imputer | |
| return blue, orange | |
| def fig3(reps=8, n_jobs=24): | |
| res = {"n": 2000, "p": 10, "replicates": reps, "null_coordinate": 0} | |
| for kind in ("rf", "nn", "gb"): | |
| got = Parallel(n_jobs=n_jobs)(delayed(_fig3_rep)(r, kind) for r in range(reps)) | |
| blue = np.concatenate([g[0] for g in got]) | |
| orange = np.concatenate([g[1] for g in got]) | |
| res[kind] = { | |
| "blue_std": float(np.std(blue)), | |
| "orange_std": float(np.std(orange)), | |
| "blue_iqr": float(np.subtract(*np.percentile(blue, [75, 25]))), | |
| "orange_iqr": float(np.subtract(*np.percentile(orange, [75, 25]))), | |
| "concentration_ratio_std": float(np.std(blue) / np.std(orange)), | |
| "blue_mean": float(np.mean(blue)), | |
| "orange_mean": float(np.mean(orange)), | |
| "blue_sample": blue[:4000].tolist(), | |
| "orange_sample": orange[:4000].tolist(), | |
| } | |
| print( | |
| f"[fig3] {kind}: blue_std={np.std(blue):.5f} " | |
| f"orange_std={np.std(orange):.5f} " | |
| f"ratio={np.std(blue)/np.std(orange):.2f}", | |
| flush=True, | |
| ) | |
| return res | |
| # ------------------------------------------------------------- compound rate | |
| def _rate_rep(rep, n, kind, j, p=10): | |
| rng = np.random.default_rng(SEED0 + 29_000_000 + 313 * rep + 11 * n) | |
| X, y, Sigma = _gen(rng, n, p) | |
| m = _model(kind, X, y, rep) | |
| cols = [k for k in range(p) if k != j] | |
| nu = gaussian_nu(X, j, Sigma) | |
| nu_h = Ridge(alpha=1.0).fit(X[:, cols], X[:, j]).predict(X[:, cols]) | |
| b_n = float(np.linalg.norm(nu_h - nu) / np.sqrt(n)) # ||nu_hat - nu|| | |
| # a_n : empirical sensitivity of m_hat to coordinate j. Tree ensembles are | |
| # piecewise constant, so an infinitesimal derivative is identically 0; we | |
| # use a SECANT sensitivity at scale h = 0.25 sd(X^j), which reduces to the | |
| # derivative for smooth models and stays informative for trees. | |
| h = 0.25 * float(np.std(X[:, j])) | |
| Xp, Xm = X.copy(), X.copy() | |
| Xp[:, j] += h | |
| Xm[:, j] -= h | |
| a_n = float(np.mean(np.abs(m.predict(Xp) - m.predict(Xm)) / (2 * h))) | |
| perm = rng.permutation(n) | |
| e_or = X[:, j] - nu | |
| e_es = X[:, j] - nu_h | |
| def build(base, eps): | |
| Z = X.copy() | |
| Z[:, j] = base + eps[perm] | |
| return Z | |
| d = sq_loss(m.predict(build(nu_h, e_es)), y) - sq_loss( | |
| m.predict(build(nu, e_or)), y | |
| ) | |
| # Theorem 4.3 is a POINTWISE O_P statement, so use pointwise scales of |d| | |
| # rather than the average over i (which would add its own 1/sqrt(n)). | |
| return (a_n, b_n, float(np.mean(np.abs(d))), float(np.quantile(np.abs(d), 0.9))) | |
| def _slope(ns, v): | |
| v = np.asarray(v, float) | |
| ok = v > 0 | |
| if ok.sum() < 3: | |
| return float("nan") | |
| return float(np.polyfit(np.log(np.asarray(ns)[ok]), np.log(v[ok]), 1)[0]) | |
| def rate(ns, kind, j, reps=40, n_jobs=100, label=""): | |
| A, B, Dm, Dq = [], [], [], [] | |
| for n in ns: | |
| got = Parallel(n_jobs=n_jobs)( | |
| delayed(_rate_rep)(r, n, kind, j) for r in range(reps) | |
| ) | |
| arr = np.array(got) | |
| A.append(float(np.median(arr[:, 0]))) | |
| B.append(float(np.median(arr[:, 1]))) | |
| Dm.append(float(np.median(arr[:, 2]))) | |
| Dq.append(float(np.median(arr[:, 3]))) | |
| sa, sb = _slope(ns, A), _slope(ns, B) | |
| sdm, sdq = _slope(ns, Dm), _slope(ns, Dq) | |
| res = { | |
| "ns": list(ns), | |
| "model": kind, | |
| "coordinate": j, | |
| "null": bool(j == 0), | |
| "replicates": reps, | |
| "a_n": A, | |
| "b_n": B, | |
| "D_n_mean_abs": Dm, | |
| "D_n_q90_abs": Dq, | |
| "exponent_a_n": sa, | |
| "exponent_b_n": sb, | |
| "exponent_D_n_mean_abs": sdm, | |
| "exponent_D_n_q90_abs": sdq, | |
| "predicted_compound_exponent": sa + sb, | |
| "single_robust_exponent_b_only": sb, | |
| } | |
| print( | |
| f"[rate {label}] a_n exp={sa:+.3f} b_n exp={sb:+.3f} " | |
| f"=> compound pred={sa+sb:+.3f} | measured D_n exp: " | |
| f"mean|d|={sdm:+.3f} q90|d|={sdq:+.3f}", | |
| flush=True, | |
| ) | |
| return res | |
| if __name__ == "__main__": | |
| os.makedirs(OUT, exist_ok=True) | |
| t0 = time.time() | |
| ns = [100, 200, 400, 800, 1600, 3200, 6400] | |
| res = {"seed0": SEED0} | |
| res["figure3"] = fig3() | |
| res["rate_linear_null"] = rate(ns, "linear", 0, label="linear j=0 (null)") | |
| res["rate_linear_important"] = rate(ns, "linear", 1, label="linear j=1 (important)") | |
| res["rate_gb_null"] = rate(ns, "gb", 0, reps=24, label="GB j=0 (null)") | |
| res["rate_gb_important"] = rate(ns, "gb", 1, reps=24, label="GB j=1 (important)") | |
| res["rate_rf_null"] = rate(ns[:-1], "rf", 0, reps=16, label="RF j=0 (null)") | |
| res["rate_nn_null"] = rate(ns[:-1], "nn", 0, reps=16, label="NN j=0 (null)") | |
| res["rate_nn_important"] = rate( | |
| ns[:-1], "nn", 1, reps=16, label="NN j=1 (important)" | |
| ) | |
| res["seconds"] = round(time.time() - t0, 1) | |
| with open(os.path.join(OUT, "claim4_double_robustness.json"), "w") as f: | |
| json.dump(res, f, indent=2) | |
| print("wrote", os.path.join(OUT, "claim4_double_robustness.json")) | |
Xet Storage Details
- Size:
- 9.54 kB
- Xet hash:
- 6341ad0b7ab87a01ea480c503810515f043c8411a6b03399282f2e3d6eb275e9
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.