Buckets:

illusion-brain/brain-domains / scripts /exp72_singletrial.py
redmingwei's picture
download
raw
13.5 kB
#!/usr/bin/env python3
"""exp72 — 单试次编码:ViT 特征能预测**单次呈现**的 EEG 吗?
## 要回答的问题
exp71 定了:train split(16540 图 × 4 重复)的零分布地板是 −0.0012,
低到与**单试次 R² 的天花板**(≈ 单试次方差的 1%)同量级 ——
所以单试次解码唯一可能的战场是 train split。
本脚本就在那一格上直接测:
test split (200 图 × 80 重复):单试次 R² = +0.0001,地板 −0.0121 → 不可检出
train split(16540 图 × 4 重复):单试次 R² = ? ,地板 −0.0012 → ?
## 一个让这个实验几乎免费的关键等式
同一张图的所有重复**共享同一个特征向量**(特征是逐图像的),
所以试次级 ridge 的正规方程只依赖**每图的试次和 S1 与试次平方和 S2**:
min_w Σ_{i,t} (x_i·w - y_{i,t})² + λ||w||²
⇒ (n_rep·G + λI) w = Xᵀ S1, G = Σ_i x_i x_iᵀ, S1_i = Σ_t y_{i,t}
令 ȳ_i = S1_i / n_rep:正规方程可写成 (G + (λ/n_rep)·I) w = Xᵀ ȳ,
**权重在 λ' = λ/n_rep 的映射下与试次级完全一致**(对拍偏差 ~1e-16)。
更重要的是:**给定同一个 w**,图级公式还原出的 R² 与试次级直接算**完全一致**
(对拍偏差 ~1e-16,两种粒度 × 有信号/纯噪声四种情形全对)——
这才是本实验的算力节省所依赖的性质。
ss_res_single = Σ_i [ (S2_i - n_rep·ȳ_i²) + n_rep·(ȳ_i - pred_i)² ]
ss_tot_single = Σ_i [ S2_i - 2·ȳ_tr·S1_i + n_rep·ȳ_tr² ]
ss_res_avg = Σ_i [ n_rep·(ȳ_i - pred_i)² ]
ss_tot_avg = Σ_i [ n_rep·(ȳ_i - ȳ_tr)² ]
(ȳ_tr = **训练集**均值:single 用训练试次均值,avg 用训练图像均值。)
## 判据
窗口 **200–400 ms 先验指定**(exp64/65/66/67 的 RSA 独立结果),不做窗口选择
⇒ 观测与零分布形式一致 ⇒ p 值诚实。
单试次 R² > 零分布 95 分位 ⇒ 单试次解码可行
"""
from __future__ import annotations
import json
import os
import time
from pathlib import Path
import numpy as np
for _v in ("OMP_NUM_THREADS", "OPENBLAS_NUM_THREADS", "MKL_NUM_THREADS"):
os.environ.setdefault(_v, "8")
FS = 250.0
WIN = 25
BASELINE = 12
N_FOLDS = 5
N_NULL = 10
ALPHAS = [0.01, 0.1, 1.0, 10.0, 100.0, 1000.0]
PRIOR_BINS = (2, 4) # 200–400 ms(先验指定)
SEED = 0
TEST_4D = Path("/domains/omnibrain4d/eeg") # 200 图 × 80 重复
TRAIN_4D = Path("/domains/omnibrain4d/eeg_train") # 16540 图 × 4 重复
FEAT_TEST = "/training/eeg/eeg-image-decode/ViT-H-14_features_test.pt"
FEAT_TRAIN = "/training/eeg/eeg-image-decode/ViT-H-14_features_train.pt"
OUT_DIR = Path("/domains/runs/tier0-encoding")
N_SUBJ_TEST = 10
N_SUBJ_TRAIN = 5
def log(*a):
print(*a, flush=True)
def make_folds(n, k, rng):
idx = rng.permutation(n)
return [(np.setdiff1d(idx, te), te) for te in np.array_split(idx, k)]
def per_rep_targets(eeg, chunk=400):
"""(n_img, n_rep, n_ch, T) → (n_img, n_rep, n_ch*n_bin) 每个重复的窗均值(窗主序)。"""
n_img, n_rep, n_ch, T = eeg.shape
n_bin = T // WIN
out = np.empty((n_img, n_rep, n_ch * n_bin), dtype=np.float32)
for s in range(0, n_img, chunk):
e = eeg[s:s + chunk].astype(np.float32)
e -= e[:, :, :, :BASELINE].mean(axis=3, keepdims=True)
e = e.reshape(e.shape[0], n_rep, n_ch, n_bin, WIN).mean(axis=4)
e = e.transpose(0, 1, 3, 2) # (c, rep, bin, ch)
out[s:s + chunk] = e.reshape(e.shape[0], n_rep, n_ch * n_bin)
del e
return out
def r2_from_stats(S1, S2, Ybar, pred, n_rep, mode, ybar_tr=None):
"""由 (S1, S2, ȳ, pred) 精确还原 R²。
mode='single': 目标 = 单次试次
mode='avg' : 目标 = 试次均值
基线 ybar_tr 必须是**训练集**的均值(与 exp67/70/71 口径一致):
single 用训练**试次**均值;avg 用训练**图像**均值。
"""
if mode == "single":
if ybar_tr is None:
ybar_tr = S1.sum(axis=0) / (len(S1) * n_rep)
ss_res = ((S2 - n_rep * Ybar ** 2) + n_rep * (Ybar - pred) ** 2).sum(axis=0)
ss_tot = (S2 - 2 * ybar_tr * S1 + n_rep * ybar_tr ** 2).sum(axis=0)
else:
if ybar_tr is None:
ybar_tr = Ybar.mean(axis=0)
ss_res = (n_rep * (Ybar - pred) ** 2).sum(axis=0)
ss_tot = (n_rep * (Ybar - ybar_tr) ** 2).sum(axis=0)
return 1.0 - ss_res / np.maximum(ss_tot, 1e-12)
def fit_image_level(X, S1, S2, Ybar, n_rep, folds, alphas, cols, n_null, rng,
want_profile=False):
"""图级 primal ridge;cols 指定参与 R² 统计的目标列(先验窗)。
返回 dict:alpha / r2_single / r2_avg / nulls / p / profile
"""
import scipy.linalg as sla
n, d = X.shape
Yc = Ybar[:, cols]
S1c, S2c = S1[:, cols], S2[:, cols]
n_t = len(cols)
real_s = {a: None for a in alphas}
real_a = {a: None for a in alphas}
prof = {a: None for a in alphas} if want_profile else None
for tr, te in folds:
Xtr, Xte = X[tr], X[te]
mu = Xtr.mean(axis=0, keepdims=True)
sd = Xtr.std(axis=0, keepdims=True) + 1e-6
Xtr = (Xtr - mu) / sd
Xte = (Xte - mu) / sd
XtX = (Xtr.T @ Xtr).astype(np.float64)
scale = float(np.trace(XtX)) / d
# 试次级正规方程 ÷ n_rep ⇒ 图级(等价,已对拍验证)
A0 = XtX / n_rep
ybar_tr = S1c[tr].sum(axis=0) / (len(tr) * n_rep)
Ytr_c = Yc[tr] - ybar_tr
Yte_c = Yc[te]
XtY = Xtr.T @ Ytr_c
for a in alphas:
A = A0.copy()
A.flat[:: d + 1] += a * scale
W = sla.solve(A, XtY, assume_a="pos", overwrite_a=True, check_finite=False)
pred = Xte @ W + ybar_tr
rs = r2_from_stats(S1c[te], S2c[te], Yc[te], pred, n_rep, "single",
ybar_tr=ybar_tr)
ra = r2_from_stats(S1c[te], S2c[te], Yc[te], pred, n_rep, "avg",
ybar_tr=Yc[tr].mean(axis=0))
real_s[a] = rs if real_s[a] is None else real_s[a] + rs
real_a[a] = ra if real_a[a] is None else real_a[a] + ra
if prof is not None:
pr = (rs.reshape(-1, 63) if n_t == 630 else None)
prof[a] = pr if prof[a] is None else prof[a] + pr
nf = len(folds)
best_a, best_s, best_av = None, None, None
for a in alphas:
s = float(np.median(real_s[a] / nf))
if best_a is None or s > best_s:
best_a, best_s = a, s
best_av = float(np.median(real_a[a] / nf))
profile = None
if prof is not None and prof[best_a] is not None:
profile = [float(x) for x in np.median((prof[best_a] / nf).reshape(10, 63), axis=1)]
# ---- 零分布:置换「图像 ↔ 目标」对应关系(同一先验窗、同一 α)----
nulls_s = np.zeros(n_null)
nulls_a = np.zeros(n_null)
for tr, te in folds:
Xtr, Xte = X[tr], X[te]
mu = Xtr.mean(axis=0, keepdims=True)
sd = Xtr.std(axis=0, keepdims=True) + 1e-6
Xtr = (Xtr - mu) / sd
Xte = (Xte - mu) / sd
XtX = (Xtr.T @ Xtr).astype(np.float64)
A0 = XtX / n_rep
scale = float(np.trace(XtX)) / d
ybar_tr = S1c[tr].sum(axis=0) / (len(tr) * n_rep)
for pi in range(n_null):
p = rng.permutation(len(tr))
Ytr_p = Yc[tr][p] - ybar_tr
A = A0.copy()
A.flat[:: d + 1] += best_a * scale
W = sla.solve(A, Xtr.T @ Ytr_p, assume_a="pos", overwrite_a=True,
check_finite=False)
pred = Xte @ W + ybar_tr
nulls_s[pi] += float(np.median(
r2_from_stats(S1c[te], S2c[te], Yc[te], pred, n_rep, "single",
ybar_tr=ybar_tr)))
nulls_a[pi] += float(np.median(
r2_from_stats(S1c[te], S2c[te], Yc[te], pred, n_rep, "avg",
ybar_tr=Yc[tr].mean(axis=0))))
nulls_s /= nf
nulls_a /= nf
return {"alpha": best_a, "r2_single": best_s, "r2_avg": best_av,
"null_single": nulls_s, "null_avg": nulls_a,
"profile_single": profile}
def run_split(name, feat_path, data_dir, n_subj, rng, cols):
import torch
f = torch.load(feat_path, map_location="cpu", weights_only=False)
IMG = f["img_features"].numpy().astype(np.float64)
log(f"\n===== {name} =====")
log(f" 特征 {IMG.shape}")
# 载入各被试的 (S1, S2, ȳ)
stats = {}
for sid in range(1, n_subj + 1):
fp = data_dir / f"sub-{sid:02d}.npz"
if not fp.exists():
continue
t0 = time.time()
eeg = np.load(fp)["eeg"]
Yrep = per_rep_targets(eeg)
del eeg
n_img, n_rep = Yrep.shape[0], Yrep.shape[1]
S1 = Yrep.sum(axis=1)
S2 = (Yrep.astype(np.float64) ** 2).sum(axis=1)
Ybar = S1 / n_rep
stats[sid] = (S1, S2, Ybar, n_rep)
log(f" sub-{sid:02d}: {n_img} 图 × {n_rep} 重复 [{time.time()-t0:.0f}s]")
del Yrep
rows = []
for sid, (S1, S2, Ybar, n_rep) in stats.items():
t0 = time.time()
folds = make_folds(Ybar.shape[0], N_FOLDS, rng)
r = fit_image_level(IMG, S1, S2, Ybar, n_rep, folds, ALPHAS, cols,
N_NULL, rng, want_profile=(sid == min(stats)))
p_s = float((np.sum(r["null_single"] >= r["r2_single"]) + 1) / (N_NULL + 1))
p_a = float((np.sum(r["null_avg"] >= r["r2_avg"]) + 1) / (N_NULL + 1))
rec = {"split": name, "subject": f"sub-{sid:02d}",
"n_img": int(Ybar.shape[0]), "n_rep": int(n_rep),
"window_ms": [PRIOR_BINS[0] * 100, PRIOR_BINS[1] * 100],
"alpha": r["alpha"],
"r2_single_trial": r["r2_single"], "p_single_trial": p_s,
"null_single_median": float(np.median(r["null_single"])),
"null_single_p95": float(np.percentile(r["null_single"], 95)),
"r2_trial_avg": r["r2_avg"], "p_trial_avg": p_a,
"null_avg_median": float(np.median(r["null_avg"])),
"null_avg_p95": float(np.percentile(r["null_avg"], 95)),
"profile_single": r["profile_single"],
"elapsed_s": round(time.time() - t0, 1)}
rows.append(rec)
log(f" sub-{sid:02d}: 单试次 R²={r['r2_single']:+.6f} "
f"(零 {rec['null_single_median']:+.6f}, 95% {rec['null_single_p95']:+.6f}, p={p_s:.3f}) | "
f"试次均值 R²={r['r2_avg']:+.6f} (p={p_a:.3f}) [{rec['elapsed_s']}s]")
(OUT_DIR / "exp72_singletrial.json").write_text(
json.dumps({"per_subject": rows}, indent=2, ensure_ascii=False),
encoding="utf-8")
return rows
def main():
t0 = time.time()
OUT_DIR.mkdir(parents=True, exist_ok=True)
rng = np.random.default_rng(SEED)
lo, hi = PRIOR_BINS
cols = np.arange(lo * 63, hi * 63)
report = {"schema_version": "1.0", "script": "exp72_singletrial",
"started": time.strftime("%Y-%m-%dT%H:%M:%S"),
"design": {"window_ms": [lo * 100, hi * 100],
"window_source": "先验指定(RSA 独立结果),不做窗口选择",
"equivalence": "图级降维 ≡ 试次级 ridge(权重偏差 ~1e-16,已对拍)",
"cv": "5-fold over IMAGES",
"null": "permuting image↔target correspondence, 同一先验窗"}}
rows_test = run_split("test(200图×80重复)", FEAT_TEST, TEST_4D,
N_SUBJ_TEST, rng, cols)
rows_train = run_split("train(16540图×4重复)", FEAT_TRAIN, TRAIN_4D,
N_SUBJ_TRAIN, rng, cols)
rows = rows_test + rows_train
log("\n" + "=" * 84)
log("汇总:单试次编码能否检出")
log("=" * 84)
summary = {}
for nm, rs in (("test", rows_test), ("train", rows_train)):
if not rs:
continue
s = {"n_subjects": len(rs),
"r2_single_median": float(np.median([r["r2_single_trial"] for r in rs])),
"null_single_median": float(np.median([r["null_single_median"] for r in rs])),
"p_single_median": float(np.median([r["p_single_trial"] for r in rs])),
"n_sig_single": sum(1 for r in rs if r["p_single_trial"] <= 1.0 / (N_NULL + 1) + 1e-9),
"r2_avg_median": float(np.median([r["r2_trial_avg"] for r in rs]))}
s["detectable"] = bool(s["n_sig_single"] >= max(2, len(rs) // 2))
summary[nm] = s
log(f" {nm:5s}: 单试次 R² 中位 {s['r2_single_median']:+.6f} | "
f"零中位 {s['null_single_median']:+.6f} | p 中位 {s['p_single_median']:.3f} | "
f"达最小p {s['n_sig_single']}/{len(rs)} | 试次均值 R² {s['r2_avg_median']:+.6f} | "
f"{'✅ 可检出' if s['detectable'] else '❌ 不可检出'}")
report["summary"] = summary
report["per_subject"] = rows
report["elapsed_s"] = round(time.time() - t0, 1)
(OUT_DIR / "exp72_singletrial_full.json").write_text(
json.dumps(report, indent=2, ensure_ascii=False), encoding="utf-8")
print("=== JSON BEGIN ===")
print(json.dumps(summary, indent=2, ensure_ascii=False))
print("=== JSON END ===")
log(f"\n耗时 {time.time()-t0:.0f}s")
if __name__ == "__main__":
main()

Xet Storage Details

Size:
13.5 kB
·
Xet hash:
3a13aec097d1b7f8947ab3a23c6b101809b80a549051f75f249f0140d04501a2

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