Spaces:
Running
Running
Repurpose as ICML-2026 repro logbook: Semi-knockoffs (arXiv:2601.23124, Xf9hJMGwDd)
97afa54 verified | """Semi-knockoffs (arXiv:2601.23124v1), implemented from the paper's algorithms. | |
| Algorithm 1 (SKO-Wcx), verbatim: | |
| Fit nu_j ~= E[X^j | X^{-j}] | |
| Fit rho_j ~= E[X^j | X^{-j}, y] | |
| eps_{j,1} = X^j - nu_j(X^{-j}) | |
| eps_{j,2} = X^j - rho_j(X^{-j}, y) | |
| draw permutations pi_{j,1}, pi_{j,2} of {1..n} | |
| Xt1_i = nu_j(X^{-j}_i) + eps_{j,1, pi1(i)} | |
| Xt2_i = rho_j(X^{-j}_i, y_i) + eps_{j,2, pi2(i)} | |
| Wilcoxon paired test between {l(m(Xt1_i), y_i)} and {l(m(Xt2_i), y_i)} | |
| Why it is valid (Section 3.1): under H0, rho_j(X^{-j}, y) = E[X^j | X^{-j}, y] | |
| = E[X^j | X^{-j}] = nu_j(X^{-j}), so the two copies are drawn from the *same* | |
| distribution and the paired differences are symmetric about zero — hence a | |
| nonparametric paired test (sign / Wilcoxon) is exact in finite samples. A t-test | |
| is explicitly NOT valid here because the variance vanishes under the null. | |
| Under H1, y carries information about X^j, so rho_j predicts X^j better than | |
| nu_j, copy 2 is perturbed less, and its loss is lower. | |
| FDR (Section 3.2 + Eq. 1): the signed statistic W_j has a sign that is a fair | |
| coin under the null, so the standard knockoff+ threshold applies: | |
| T_q = min{ t in |W| : (1 + #{W_j <= -t}) / (#{W_j >= t} v 1) <= q } | |
| S = { j : W_j >= T_q } | |
| """ | |
| import numpy as np | |
| from scipy.stats import wilcoxon | |
| from sklearn.ensemble import GradientBoostingRegressor, RandomForestRegressor | |
| from sklearn.linear_model import RidgeCV | |
| from sklearn.neural_network import MLPRegressor | |
| def _fit_predict(model, Z, target): | |
| model.fit(Z, target) | |
| return model.predict(Z) | |
| def _regressor(kind, seed): | |
| if kind == "ridge": | |
| return RidgeCV(alphas=np.logspace(-3, 3, 13)) | |
| if kind == "rf": | |
| return RandomForestRegressor(n_estimators=100, random_state=seed, n_jobs=-1) | |
| if kind == "gb": | |
| return GradientBoostingRegressor(random_state=seed) | |
| if kind == "nn": | |
| return MLPRegressor(hidden_layer_sizes=(64, 32), max_iter=600, | |
| random_state=seed) | |
| raise ValueError(kind) | |
| def semi_knockoff_losses(X, y, j, model, rng, nuisance="ridge", seed=0): | |
| """Return the two paired loss vectors for feature j (Algorithm 1).""" | |
| n = len(y) | |
| Xmj = np.delete(X, j, axis=1) | |
| xj = X[:, j] | |
| nu = _fit_predict(_regressor(nuisance, seed), Xmj, xj) | |
| rho = _fit_predict(_regressor(nuisance, seed), | |
| np.column_stack([Xmj, y]), xj) | |
| e1 = xj - nu | |
| e2 = xj - rho | |
| p1 = rng.permutation(n) | |
| p2 = rng.permutation(n) | |
| Xt1 = X.copy(); Xt1[:, j] = nu + e1[p1] | |
| Xt2 = X.copy(); Xt2[:, j] = rho + e2[p2] | |
| l1 = (model.predict(Xt1) - y) ** 2 | |
| l2 = (model.predict(Xt2) - y) ** 2 | |
| return l1, l2 | |
| def sko_pvalue(X, y, j, model, rng, nuisance="ridge", seed=0): | |
| """Algorithm 1: one-sided Wilcoxon signed-rank p-value for H0: j is null.""" | |
| l1, l2 = semi_knockoff_losses(X, y, j, model, rng, nuisance, seed) | |
| d = l1 - l2 | |
| if np.allclose(d, 0): | |
| return 1.0 | |
| # H1: copy 2 (y-aware) has the SMALLER loss, i.e. d > 0 | |
| return float(wilcoxon(d, alternative="greater", zero_method="zsplit").pvalue) | |
| def sko_statistic(X, y, j, model, rng, nuisance="ridge", seed=0): | |
| """Signed statistic W_j: mean loss of copy 1 minus copy 2. | |
| Under H0 the two copies are exchangeable, so sign(W_j) is a fair coin. | |
| Under H1, W_j > 0. | |
| """ | |
| l1, l2 = semi_knockoff_losses(X, y, j, model, rng, nuisance, seed) | |
| return float(l1.mean() - l2.mean()) | |
| def knockoff_plus_threshold(W, q): | |
| """Eq. (1): the knockoff+ data-dependent threshold.""" | |
| W = np.asarray(W, float) | |
| cand = np.sort(np.unique(np.abs(W[W != 0]))) | |
| for t in cand: | |
| num = 1 + np.sum(W <= -t) | |
| den = max(np.sum(W >= t), 1) | |
| if num / den <= q: | |
| return float(t) | |
| return float("inf") | |
| def sko_select(X, y, model, rng, q=0.2, nuisance="ridge", seed=0): | |
| """Algorithm 3: FDR-controlled selection at level q.""" | |
| W = np.array([sko_statistic(X, y, j, model, rng, nuisance, seed) | |
| for j in range(X.shape[1])]) | |
| T = knockoff_plus_threshold(W, q) | |
| return np.where(W >= T)[0], W, T | |
| def fdp_power(selected, nonnull): | |
| sel = set(int(s) for s in selected) | |
| nn = set(int(s) for s in nonnull) | |
| if not sel: | |
| return 0.0, 0.0 | |
| fdp = len(sel - nn) / len(sel) | |
| power = len(sel & nn) / max(len(nn), 1) | |
| return fdp, power | |
| def ar1_design(n, p, rho, rng): | |
| """AR(1) correlated Gaussian design.""" | |
| Z = rng.standard_normal((n, p)) | |
| X = np.empty_like(Z) | |
| X[:, 0] = Z[:, 0] | |
| s = np.sqrt(1 - rho ** 2) | |
| for j in range(1, p): | |
| X[:, j] = rho * X[:, j - 1] + s * Z[:, j] | |
| return X | |