SabaPivot's picture
download
raw
8.73 kB
"""Claim 6 (Figure 6, Section 5.2).
"On the Wisconsin Breast Cancer real dataset, Semi-knockoffs is applied across
Random Forest, Neural Network, and Gradient Boosting models to demonstrate
model-agnostic feature selection."
Data: sklearn.datasets.load_breast_cancer -> the Wisconsin Diagnostic Breast
Cancer (WDBC) set, n = 569, p = 30 (matching Appendix F.6).
Exactly as in Appendix F.6, the true support is unknown, so an ARTIFICIAL NULL
feature correlated at 0.6 with the original inputs is appended; the rejection
rate of that feature over repetitions estimates the type-I error.
Reported per model (Random Forest / Neural Network / Gradient Boosting), over
repetitions with distinct seeds:
* number of discoveries at alpha = 0.05 (Semi-knockoffs Wilcoxon, no split)
* the same with 5-permutation derandomisation
* HRT with a 50/50 split, as the split-based comparator
* the artificial-null rejection rate = estimated type-I error
* stability of the discovery set across seeds (the paper's claim that
Semi-knockoffs "exhibits stable power across models and consistent numbers
of discoveries, unlike other methods whose results vary significantly with
the random seed")
* the FDR-controlling knockoff-threshold variant (Algorithm 4) at q = 0.1
"""
from __future__ import annotations
import json
import os
import sys
import time
import numpy as np
from joblib import Parallel, delayed
from sklearn.datasets import load_breast_cancer
from sklearn.ensemble import GradientBoostingClassifier, RandomForestClassifier
from sklearn.linear_model import Ridge
from sklearn.neural_network import MLPClassifier
from sklearn.preprocessing import StandardScaler
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__))))
from semiknockoffs import ( # noqa: E402
fit_nu_rho,
hrt_pvalue,
knockoff_select,
logloss,
sko_pvalue,
sko_statistic,
)
OUT = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "outputs"
)
SEED0 = 20260725
ALPHA = 0.05
Q = 0.1
QS = [0.1, 0.2, 0.3, 0.5]
def load_wdbc(rng, corr=0.6):
d = load_breast_cancer()
X = StandardScaler().fit_transform(d.data)
y = d.target.astype(float)
n, p = X.shape
# artificial null feature correlated at ~0.6 with the original inputs:
# a normalised random combination of the standardised columns plus noise.
w = rng.standard_normal(p)
w /= np.linalg.norm(w)
base = X @ w
base /= np.std(base)
z = corr * base + np.sqrt(1 - corr**2) * rng.standard_normal(n)
Xa = np.column_stack([X, z])
names = list(d.feature_names) + ["ARTIFICIAL_NULL"]
return Xa, y, names
def _model(kind, X, y, seed):
if kind == "rf":
return RandomForestClassifier(
n_estimators=100, random_state=seed, n_jobs=1
).fit(X, y)
if kind == "nn":
return MLPClassifier(
hidden_layer_sizes=(64, 64), max_iter=800, random_state=seed
).fit(X, y)
if kind == "gb":
return GradientBoostingClassifier(random_state=seed).fit(X, y)
raise ValueError(kind)
def _rep(rep, kind, K=200):
rng = np.random.default_rng(SEED0 + 53_000_000 + 977 * rep)
X, y, names = load_wdbc(rng)
n, p = X.shape
m_full = _model(kind, X, y, rep)
def pred_full(Z):
return m_full.predict_proba(Z)[:, 1]
idx = rng.permutation(n)
tr, te = idx[: n // 2], idx[n // 2 :]
m_split = _model(kind, X[tr], y[tr], rep)
def pred_split(Z):
return m_split.predict_proba(Z)[:, 1]
acc_full = float(np.mean((pred_full(X) > 0.5) == (y > 0.5)))
acc_split = float(np.mean((pred_split(X[te]) > 0.5) == (y[te] > 0.5)))
p_sko = np.ones(p)
p_sko5 = np.ones(p)
p_hrt = np.ones(p)
W = np.zeros(p)
for j in range(p):
cols = [k for k in range(p) if k != j]
nu, rho = fit_nu_rho(X, y, j, alpha=1.0)
p_sko[j] = sko_pvalue(X, y, j, nu, rho, pred_full, logloss, rng)
p_sko5[j] = sko_pvalue(X, y, j, nu, rho, pred_full, logloss, rng, n_perm=5)
W[j] = sko_statistic(X, y, j, nu, rho, pred_full, logloss, rng, n_perm=5)
rg = Ridge(alpha=1.0).fit(X[np.ix_(tr, cols)], X[tr, j])
res_tr = X[tr, j] - rg.predict(X[np.ix_(tr, cols)])
dfc = np.sqrt(len(tr) / max(1.0, len(tr) - p))
p_hrt[j] = hrt_pvalue(
X[te],
y[te],
j,
rg.predict(X[np.ix_(te, cols)]),
res_tr * dfc,
pred_split,
logloss,
rng,
K=K,
)
return {
"acc_full": acc_full,
"acc_split": acc_split,
"p_sko": p_sko.tolist(),
"p_sko5": p_sko5.tolist(),
"p_hrt": p_hrt.tolist(),
"W": W.tolist(),
"sel_knockoff": {str(q): knockoff_select(W, q).tolist() for q in QS},
"names": names,
}
def run(kind, reps=50, n_jobs=50):
t0 = time.time()
got = Parallel(n_jobs=n_jobs)(delayed(_rep)(r, kind) for r in range(reps))
names = got[0]["names"]
p = len(names)
null_idx = p - 1
res = {
"model": kind,
"replicates": reps,
"n": 569,
"p_with_artificial": p,
"accuracy_no_split_insample": float(np.mean([g["acc_full"] for g in got])),
"accuracy_split_heldout": float(np.mean([g["acc_split"] for g in got])),
"seconds": round(time.time() - t0, 1),
}
for key, lab in (
("p_sko", "SKO_Wcx"),
("p_sko5", "SKO_Wcx_p5"),
("p_hrt", "HRT_split"),
):
P = np.array([g[key] for g in got]) # (reps, p)
disc = P <= ALPHA
n_disc = disc[:, :null_idx].sum(1) # real features only
res[lab] = {
"mean_discoveries": float(n_disc.mean()),
"sd_discoveries": float(n_disc.std(ddof=1)),
"min_discoveries": int(n_disc.min()),
"max_discoveries": int(n_disc.max()),
"artificial_null_rejection_rate": float(disc[:, null_idx].mean()),
"jaccard_stability_across_seeds": _jaccard(disc[:, :null_idx]),
}
print(
f"[{kind}] {lab:11s} disc={n_disc.mean():.2f}+-{n_disc.std(ddof=1):.2f} "
f"typeI(artificial null)={disc[:, null_idx].mean():.3f} "
f"jaccard={res[lab]['jaccard_stability_across_seeds']:.3f}",
flush=True,
)
res["SKO_knockoff_threshold"] = {}
for q in QS:
sel = [set(g["sel_knockoff"][str(q)]) for g in got]
nsel = np.array([len([s for s in x if s != null_idx]) for x in sel])
res["SKO_knockoff_threshold"][str(q)] = {
"mean_selected": float(nsel.mean()),
"sd_selected": float(nsel.std(ddof=1)),
"artificial_null_selection_rate": float(
np.mean([null_idx in s for s in sel])
),
}
print(
f"[{kind}] knockoff q={q}: sel={nsel.mean():.2f}"
f"+-{nsel.std(ddof=1):.2f} artificial-null selected in "
f"{np.mean([null_idx in s for s in sel]):.3f}",
flush=True,
)
# per-feature rejection frequency for SKO_Wcx_p5
P5 = np.array([g["p_sko5"] for g in got])
res["per_feature_rejection_rate_sko_p5"] = {
names[j]: float(np.mean(P5[:, j] <= ALPHA)) for j in range(p)
}
return res
def _jaccard(disc):
"""Mean pairwise Jaccard similarity of the selected sets across seeds."""
r = disc.shape[0]
vals = []
for a in range(r):
for b in range(a + 1, r):
u = np.logical_or(disc[a], disc[b]).sum()
i = np.logical_and(disc[a], disc[b]).sum()
vals.append(1.0 if u == 0 else i / u)
return float(np.mean(vals))
if __name__ == "__main__":
os.makedirs(OUT, exist_ok=True)
res = {
"seed0": SEED0,
"alpha": ALPHA,
"q": Q,
"dataset": "sklearn.datasets.load_breast_cancer (WDBC, n=569, p=30)",
}
for kind in ("rf", "nn", "gb"):
res[kind] = run(kind)
# cross-model agreement of the SKO_Wcx_p5 discovery sets = model-agnosticism
keys = list(res["rf"]["per_feature_rejection_rate_sko_p5"].keys())
mat = {
k: [res[m]["per_feature_rejection_rate_sko_p5"][k] for m in ("rf", "nn", "gb")]
for k in keys
}
stable = [k for k, v in mat.items() if min(v) >= 0.8]
res["cross_model"] = {
"features_rejected_in_>=80pct_of_seeds_by_ALL_three_models": stable,
"n_such_features": len(stable),
"per_feature_rate_by_model": mat,
}
print("cross-model stable discoveries:", len(stable), stable)
with open(os.path.join(OUT, "claim6_wdbc.json"), "w") as f:
json.dump(res, f, indent=2)
print("wrote", os.path.join(OUT, "claim6_wdbc.json"))

Xet Storage Details

Size:
8.73 kB
·
Xet hash:
93692f8c2b3ff1fd0a01730548d1fb47d37de3f2efee0f99ef1943a9019de423

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