SabaPivot's picture
download
raw
9.34 kB
"""Independent re-implementation of Semi-knockoffs (arXiv 2601.23124) and HRT.
Everything here is written from the paper's Algorithm boxes 1-4 (Appendix C) and
Eq. (1) (knockoff threshold). No code from the authors' repository
(https://github.com/AngelReyero/loss_based_KO) was consulted or used.
Key objects
-----------
sko_statistic(...) -> W^j_SKO (Algorithm 3 oracle / Algorithm 4 estimated)
sko_pvalue(...) -> Wilcoxon / sign-test p-value (Algorithm 1 / 2)
knockoff_threshold(W, q) -> T_q of Eq. (1) (knockoff+ form, with the "1 +")
hrt_pvalue(...) -> Holdout Randomization Test (Tansey et al., 2022)
gaussian_nu / gaussian_rho-> ORACLE conditional expectations for a Gaussian
linear model, used to test the "Given nu_j, rho_j"
hypothesis of Theorems 3.3 / 3.4 exactly.
"""
from __future__ import annotations
import numpy as np
from scipy import stats
from sklearn.linear_model import Ridge, LinearRegression
# --------------------------------------------------------------------------
# losses
# --------------------------------------------------------------------------
def sq_loss(pred, y):
return (np.asarray(pred).ravel() - np.asarray(y).ravel()) ** 2
def logloss(prob, y, eps=1e-9):
p = np.clip(np.asarray(prob).ravel(), eps, 1 - eps)
y = np.asarray(y).ravel()
return -(y * np.log(p) + (1 - y) * np.log(1 - p))
# --------------------------------------------------------------------------
# oracle conditional expectations for the Gaussian linear model
# X ~ N(0, Sigma), y = beta' X + sigma * eps
# --------------------------------------------------------------------------
def gaussian_nu(X, j, Sigma):
"""nu_j(x^-j) = E[X^j | X^-j] = Sigma_{j,-j} Sigma_{-j,-j}^{-1} x^{-j}."""
p = Sigma.shape[0]
idx = [k for k in range(p) if k != j]
S_mj = Sigma[np.ix_(idx, idx)]
s_j = Sigma[j, idx]
coef = np.linalg.solve(S_mj, s_j)
return X[:, idx] @ coef
def gaussian_rho(X, y, j, Sigma, beta, sigma):
"""rho_j(x^-j, y) = E[X^j | X^-j, y] in the Gaussian linear model.
(X, y) is jointly Gaussian: cov(X, y) = Sigma beta, var(y) = beta'Sigma beta + sigma^2.
"""
p = Sigma.shape[0]
idx = [k for k in range(p) if k != j]
Sy = Sigma @ beta # cov(X, y), length p
vy = float(beta @ Sigma @ beta + sigma**2)
# conditioning vector Z = (X^{-j}, y)
C = np.zeros((p, p)) # cov of Z
C[: p - 1, : p - 1] = Sigma[np.ix_(idx, idx)]
C[: p - 1, -1] = Sy[idx]
C[-1, : p - 1] = Sy[idx]
C[-1, -1] = vy
c = np.concatenate([Sigma[j, idx], [Sy[j]]]) # cov(X^j, Z)
coef = np.linalg.solve(C, c)
Z = np.column_stack([X[:, idx], np.asarray(y).ravel()])
return Z @ coef
# --------------------------------------------------------------------------
# estimated conditional expectations (Algorithm 4, "Regression 1 / 2")
# --------------------------------------------------------------------------
def fit_nu_rho(X, y, j, alpha=1.0, learner="ridge"):
"""Return (nu_hat(X^-j), rho_hat(X^-j, y)) fitted on the SAME n samples.
The paper motivates an l2-regularised ERM (Eq. 2 / Theorem 4.1); we use Ridge.
"""
p = X.shape[1]
idx = [k for k in range(p) if k != j]
Xm = X[:, idx]
Xy = np.column_stack([Xm, np.asarray(y).ravel()])
if learner == "ridge":
m1, m2 = Ridge(alpha=alpha), Ridge(alpha=alpha)
else:
m1, m2 = LinearRegression(), LinearRegression()
m1.fit(Xm, X[:, j])
m2.fit(Xy, X[:, j])
return m1.predict(Xm), m2.predict(Xy)
# --------------------------------------------------------------------------
# core Semi-knockoff sampling (Algorithms 1-4)
# --------------------------------------------------------------------------
def _resample(eps, rng, scheme="perm"):
n = len(eps)
if scheme == "perm": # Algorithm 1-4: "Sample pi a permutation"
return eps[rng.permutation(n)]
elif scheme == "iid": # proof E.2: U_1..U_n iid U{1..n}
return eps[rng.integers(0, n, size=n)]
raise ValueError(scheme)
def sko_losses(X, y, j, nu_pred, rho_pred, predict, loss, rng, n_perm=1, scheme="perm"):
"""Return (L1, L2), each of length n: the paired loss populations.
L1_i = l(m(X~1_i), y_i) with X~1_i^j = nu(X_i^-j) + eps_{1, pi1(i)}
L2_i = l(m(X~2_i), y_i) with X~2_i^j = rho(X_i^-j, y_i) + eps_{2, pi2(i)}
n_perm > 1 performs the Rao-Blackwellisation described in Section 5
("multiple permutations per sample"): the losses are averaged over n_perm
independent permutations before the paired test.
"""
xj = X[:, j]
e1 = xj - nu_pred
e2 = xj - rho_pred
L1 = np.zeros(len(y))
L2 = np.zeros(len(y))
for _ in range(n_perm):
X1 = X.copy()
X2 = X.copy()
X1[:, j] = nu_pred + _resample(e1, rng, scheme)
X2[:, j] = rho_pred + _resample(e2, rng, scheme)
L1 += loss(predict(X1), y)
L2 += loss(predict(X2), y)
return L1 / n_perm, L2 / n_perm
def sko_statistic(
X, y, j, nu_pred, rho_pred, predict, loss, rng, n_perm=1, scheme="perm"
):
"""W^j_SKO = (1/n) sum_i [ l(m(X~1_i), y_i) - l(m(X~2_i), y_i) ]."""
L1, L2 = sko_losses(
X, y, j, nu_pred, rho_pred, predict, loss, rng, n_perm=n_perm, scheme=scheme
)
return float(np.mean(L1 - L2))
def sko_pvalue(
X,
y,
j,
nu_pred,
rho_pred,
predict,
loss,
rng,
test="wilcoxon",
n_perm=1,
scheme="perm",
):
"""Algorithm 1 / 2: one-sided nonparametric paired test, H1: L1 > L2."""
L1, L2 = sko_losses(
X, y, j, nu_pred, rho_pred, predict, loss, rng, n_perm=n_perm, scheme=scheme
)
d = L1 - L2
if test == "wilcoxon":
nz = d[d != 0]
if len(nz) == 0:
return 1.0
return float(
stats.wilcoxon(nz, alternative="greater", zero_method="wilcox").pvalue
)
elif test == "sign":
k = int(np.sum(d > 0))
m = int(np.sum(d != 0))
if m == 0:
return 1.0
return float(stats.binomtest(k, m, 0.5, alternative="greater").pvalue)
raise ValueError(test)
# --------------------------------------------------------------------------
# knockoff threshold, Eq. (1)
# --------------------------------------------------------------------------
def knockoff_threshold(W, q):
W = np.asarray(W, dtype=float)
cands = np.sort(np.unique(np.abs(W[W != 0])))
for t in cands:
num = 1 + np.sum(W <= -t)
den = max(1, np.sum(W >= t))
if num / den <= q:
return float(t)
return np.inf
def knockoff_select(W, q):
t = knockoff_threshold(W, q)
return np.where(np.asarray(W) >= t)[0]
def fdp(selected, support):
selected = np.asarray(selected, dtype=int)
if len(selected) == 0:
return 0.0
false = np.sum(~np.isin(selected, support))
return float(false) / len(selected)
def power(selected, support):
support = np.asarray(support, dtype=int)
if len(support) == 0:
return np.nan
return float(np.sum(np.isin(support, selected))) / len(support)
# --------------------------------------------------------------------------
# HRT (Tansey, Veitch, Zhang, Rabadan & Blei, JCGS 2022)
# - REQUIRES a train/test split: the model is fit on train, the randomisation
# test is run on the held-out test set.
# --------------------------------------------------------------------------
def hrt_pvalue(X_te, y_te, j, cond_mean_te, resid_pool, predict, loss, rng, K=200):
"""Holdout randomization test p-value for feature j.
cond_mean_te : nu_hat(X_te^{-j}) fitted on the TRAIN split
resid_pool : residual pool X^j - nu_hat(X^{-j}) from the TRAIN split
"""
t0 = float(np.mean(loss(predict(X_te), y_te)))
n = X_te.shape[0]
cnt = 0
for _ in range(K):
Xk = X_te.copy()
Xk[:, j] = cond_mean_te + resid_pool[rng.integers(0, len(resid_pool), size=n)]
tk = float(np.mean(loss(predict(Xk), y_te)))
if tk <= t0:
cnt += 1
return (1.0 + cnt) / (K + 1.0)
# --------------------------------------------------------------------------
# data generators from the paper
# --------------------------------------------------------------------------
def ar1_cov(p, rho):
i = np.arange(p)
return rho ** np.abs(i[:, None] - i[None, :])
def gen_adjacent(n, p, rng, rho=0.6, sparsity=0.25, noise=1.0):
"""Figure 4 setting: X ~ N(0, Sigma), Sigma_ij = 0.6^|i-j|,
y = beta'X + eps, first 0.25p coordinates of beta in [1, 2], rest 0."""
Sigma = ar1_cov(p, rho)
L = np.linalg.cholesky(Sigma)
X = rng.standard_normal((n, p)) @ L.T
k = int(round(sparsity * p))
beta = np.zeros(p)
beta[:k] = rng.uniform(1.0, 2.0, size=k)
y = X @ beta + noise * rng.standard_normal(n)
return X, y, beta, Sigma, np.arange(k)
def gen_masked(n, p, rng, rho=0.6):
"""Figure 5 setting: one relevant coordinate l, y = X_l + 0.5 eps1;
a correlated NULL variable X_{l-1} = X_l + 0.5 eps2."""
Sigma = ar1_cov(p, rho)
L = np.linalg.cholesky(Sigma)
X = rng.standard_normal((n, p)) @ L.T
l = int(rng.integers(1, p))
y = X[:, l] + 0.5 * rng.standard_normal(n)
X[:, l - 1] = X[:, l] + 0.5 * rng.standard_normal(n)
return X, y, l, np.array([l])

Xet Storage Details

Size:
9.34 kB
·
Xet hash:
39cda445e5e98a1fb73c4a0da85a173a82247e3bdf23999f4bb35f9a3df31f98

Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.