Buckets:

illusion-brain/brain-domains / scripts /exp69_train_encoding.py
redmingwei's picture
download
raw
15.8 kB
#!/usr/bin/env python3
"""exp69 — Tier 0 编码的 train split 版(图像级 16540 样本)。
## 数据结构(exp68p 实测确认)
原始 train.pt eeg (16540, 4, 63, 250) label (16540,4) text (16540,4)
label = 1654 个概念,每个概念恰好 10 张图,且**连续分组**
(行 0-9 = 概念 0,行 10-19 = 概念 1 …)
ViT 特征 img_features (16540, 1024) ← 与 EEG 图像 **1:1**
text_features (1654, 1024) ← 每概念
所以**图像级**编码是 16540 对 16540 —— 比 test split 的 200 张多 **83 倍**。
这是判断「模型特征能解释多少脑活动」的关键数字。
## 与 exp67(test split)不同的三处
1. **primal ridge**:n=16540 ≫ d=1024。
2. **置换零打乱训练折的 Y 行**:X 不动 ⇒ 同折 XᵀX 复用,每次置换省一次
13232×1024×1024 的矩阵乘;零假设下与打乱「特征↔图像对应」等价。
3. **文本特征走概念级**:text_features 只有 1654 行,要与概念级 EEG 对齐
⇒ 把每个概念的 10 张图的试次平均再平均,作为该概念的目标。
## PART 0 是硬门槛
先验证 **ViT img_features 的行序 == EEG 的图像序**:
同一概念的 10 张图在 ViT 特征空间里应比随机两张图更相似。
检验用「组内均值 − 随机配对均值」与随机配对的零分布比较。
**不通过就整个实验作废,不往下跑。**(不需要 4.17 GB 的 train.pt:
概念分组结构已由 exp68p 确认是连续 10 张一组。)
## 判据
顺序对齐 组内相似度显著高于随机配对
编码 R² img 显著高于置换零;img/BBB 可解读
"""
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_SUBJECTS = 10
N_NULL = 20
GROUPS = 10 # 每个概念 10 张图(exp68p 实测)
N_CONCEPT = 1654
ALPHAS = [0.003, 0.01, 0.03, 0.1, 0.3, 1.0, 3.0, 10.0]
SEED = 0
FEAT_TRAIN_PT = "/training/eeg/eeg-image-decode/ViT-H-14_features_train.pt"
EegTRAIN = Path("/domains/omnibrain4d/eeg_train")
OUT_DIR = Path("/domains/runs/tier0-encoding")
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 _rank(a):
from scipy.stats import rankdata
return rankdata(a)
def spearman(a, b):
ra = _rank(a).astype(np.float64)
rb = _rank(b).astype(np.float64)
ra -= ra.mean()
rb -= rb.mean()
den = np.sqrt((ra * ra).sum() * (rb * rb).sum())
return float((ra * rb).sum() / den) if den > 0 else 0.0
def rdm_full(mat):
m = np.asarray(mat, dtype=np.float64)
m = m - m.mean(axis=1, keepdims=True)
m = m / (np.linalg.norm(m, axis=1, keepdims=True) + 1e-12)
r = m @ m.T
np.fill_diagonal(r, 1.0)
return 1.0 - r
# ================================================================ PART 0
def part0_align():
"""验证 ViT img_features 行序 == EEG 图像序(概念分组结构已实测确认)。"""
import torch
log("=" * 84)
log("PART 0 — 顺序对齐验证(ViT img_features vs EEG 图像序)")
log("=" * 84)
f = torch.load(FEAT_TRAIN_PT, map_location="cpu", weights_only=False)
IMG = f["img_features"].numpy().astype(np.float32)
TXT = f["text_features"].numpy().astype(np.float32)
log(f" img_features {IMG.shape} | text_features {TXT.shape}")
del f
n = IMG.shape[0]
assert n % GROUPS == 0, f"图像数 {n} 不是 {GROUPS} 的整数倍"
V = IMG / (np.linalg.norm(IMG, axis=1, keepdims=True) + 1e-12)
# 组内对(同概念)
gid = np.repeat(np.arange(n // GROUPS), GROUPS)
# 组内对:只用一部分,避免 O(n²)
rng = np.random.default_rng(SEED)
sel = rng.choice(n // GROUPS, size=min(400, n // GROUPS), replace=False)
tu = np.triu_indices(GROUPS, 1)
ii, jj = tu[0], tu[1]
a = (sel[:, None] * GROUPS + ii[None, :]).ravel()
b = (sel[:, None] * GROUPS + jj[None, :]).ravel()
same = float(np.einsum("ij,ij->i", V[a], V[b]).mean())
# 随机配对零分布(两次独立随机抽样,允许重复)
nulls = []
for _ in range(200):
p1 = rng.integers(0, n, size=len(a))
p2 = rng.integers(0, n, size=len(a))
nulls.append(float(np.einsum("ij,ij->i", V[p1], V[p2]).mean()))
nulls = np.array(nulls)
gap = same - nulls.mean()
p = float((np.sum(nulls >= same) + 1) / (len(nulls) + 1))
log(f" 同概念 10 张图的平均余弦 = {same:+.4f}")
log(f" 随机配对零分布 = {nulls.mean():+.4f} ± {nulls.std():.4f} "
f"[95%={np.percentile(nulls,95):+.4f}]")
log(f" 差距 = {gap:+.4f} ({(gap/max(nulls.std(),1e-12)):.1f}σ) p = {p:.4f}")
ok = bool(gap > 3 * nulls.std() and p < 0.01)
log(f" → {'✅ 行序一致,可以继续' if ok else '❌ 对齐存疑,实验作废'}")
return IMG, TXT, {"same_concept_cos": same, "null_mean": float(nulls.mean()),
"null_sd": float(nulls.std()), "gap": gap, "p": p, "ok": ok}
# ================================================================ PART 1
def build_image_targets(eeg, chunk=400):
"""(n_img, n_rep, n_ch, T) → (n_img, n_ch*n_bin) 每张图的试次平均。
分块 + 一次 reshape 完成 10 个窗的均值,避免 8 GB 的 float32 副本。
"""
n_img, n_rep, n_ch, T = eeg.shape
n_bin = T // WIN
out = np.empty((n_img, 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)
# (c, rep, ch, T) → (c, rep, ch, bin, win) → 窗内平均
e = e.reshape(e.shape[0], n_rep, n_ch, n_bin, WIN).mean(axis=4) # (c,rep,ch,bin)
# ★ 必须转成**窗主序** (c, rep, bin, ch):后面 reshape(n_bin, n_ch)
# 依赖的是 [bin0_ch0 … bin0_ch62, bin1_ch0 …]。若保持 ch 主序,
# 逐窗统计会把窗与通道混排(RSA/编码两套代码的顺序必须一致)。
e = e.transpose(0, 1, 3, 2) # (c,rep,bin,ch)
e = e.mean(axis=1) # (c,bin,ch)
out[s:s + chunk] = e.reshape(e.shape[0], n_bin * n_ch)
del e
return out
def part1_targets():
"""逐被试算「每张图」的试次平均响应(BBB 需要所有被试)。"""
log("\n" + "=" * 84)
log("PART 1 — 逐被试的图像级试次平均响应")
log("=" * 84)
tas = {}
for sid in range(1, N_SUBJECTS + 1):
fp = EegTRAIN / f"sub-{sid:02d}.npz"
if not fp.exists():
log(f" [skip] sub-{sid:02d}")
continue
t0 = time.time()
eeg = np.load(fp)["eeg"]
log(f" sub-{sid:02d}: 载入 {eeg.shape} {eeg.dtype} [{time.time()-t0:.0f}s]")
Y = build_image_targets(eeg)
del eeg
tas[sid] = Y
log(f" sub-{sid:02d}: 图像级目标 {Y.shape} [{time.time()-t0:.0f}s]")
return tas
# ================================================================ PART 2
def ridge_cv_primal(X, Y, folds, alphas, n_null, rng, n_ch=63):
"""primal ridge(n > d)。返回 (best_alpha, 逐窗R²数组, 零分布数组)。
置换零:打乱**训练折的 Y 行**(X 不动 ⇒ 同折 XᵀX 复用)。
零假设「X 不含 Y 的信息」下,与打乱「特征↔图像对应」等价。
"""
n, d = X.shape
n_bin = Y.shape[1] // n_ch
real_w = {a: None for a in alphas}
null_w = [[] for _ in range(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)
ybar = Y[tr].mean(axis=0, keepdims=True)
Ytr = Y[tr] - ybar
Yte = Y[te]
ss_tot = ((Yte - ybar) ** 2).sum(axis=0)
scale = float(np.trace(XtX)) / d
XtY = Xtr.T @ Ytr
for a in alphas:
A = XtX.copy()
A.flat[:: d + 1] += a * scale
W = np.linalg.solve(A, XtY)
pred = Xte @ W + ybar
r2 = 1.0 - ((Yte - pred) ** 2).sum(axis=0) / np.maximum(ss_tot, 1e-12)
real_w[a] = r2 if real_w[a] is None else real_w[a] + r2
for pi in range(n_null):
Yp = Ytr[rng.permutation(len(tr))]
XtYp = Xtr.T @ Yp
vals = []
for a in alphas:
A = XtX.copy()
A.flat[:: d + 1] += a * scale
W = np.linalg.solve(A, XtYp)
pred = Xte @ W + ybar
r2 = 1.0 - ((Yte - pred) ** 2).sum(axis=0) / np.maximum(ss_tot, 1e-12)
vals.append(r2)
null_w[pi].append(vals)
nf = len(folds)
best = None
for a in alphas:
pw = np.median((real_w[a] / nf).reshape(n_bin, n_ch), axis=1)
if best is None or pw.max() > best[1].max():
best = (a, pw)
nulls = np.zeros((n_null, n_bin))
for pi in range(n_null):
acc = np.zeros((len(alphas), Y.shape[1]))
for vals in null_w[pi]:
acc += np.asarray(vals)
acc /= nf
bestp = None
for ai in range(len(alphas)):
pw = np.median(acc[ai].reshape(n_bin, n_ch), axis=1)
if bestp is None or pw.max() > bestp.max():
bestp = pw
nulls[pi] = bestp
return best[0], best[1], nulls
def part2_encode(IMG, TXT, tas, rng):
log("\n" + "=" * 84)
log("PART 2 — Ridge 编码(图像级 16540 样本,primal,5-fold 图像级 CV)")
log("=" * 84)
rows = []
sids = sorted(tas)
for sid in sids:
t0 = time.time()
Y = tas[sid] # (16540, 630) 图像级
n_img = Y.shape[0]
folds = make_folds(n_img, N_FOLDS, rng)
others = [tas[k] for k in sids if k != sid]
Y_bbb = np.mean(np.stack(others, axis=0), axis=0)
a_i, w_i, null_i = ridge_cv_primal(IMG, Y, folds, ALPHAS, N_NULL, rng)
a_b, w_b, _ = ridge_cv_primal(Y_bbb, Y, folds, ALPHAS, 0, rng)
# 文本特征是概念级(1654):EEG 也要聚合到概念级
Yc = Y.reshape(N_CONCEPT, GROUPS, -1).mean(axis=1) # (1654, 630)
Yc_bbb = np.mean(np.stack(
[tas[k].reshape(N_CONCEPT, GROUPS, -1).mean(axis=1) for k in sids if k != sid],
axis=0), axis=0)
folds_c = make_folds(N_CONCEPT, N_FOLDS, rng)
a_t, w_t, null_t = ridge_cv_primal(TXT, Yc, folds_c, ALPHAS, N_NULL, rng)
a_bc, w_bc, _ = ridge_cv_primal(Yc_bbb, Yc, folds_c, ALPHAS, 0, rng)
bi = int(np.argmax(w_i))
bt = int(np.argmax(w_t))
p_i = float((np.sum(null_i[:, bi] >= w_i[bi]) + 1) / (N_NULL + 1))
p_t = float((np.sum(null_t[:, bt] >= w_t[bt]) + 1) / (N_NULL + 1))
rec = {
"subject": f"sub-{sid:02d}", "n_img": n_img, "n_concept": N_CONCEPT,
"alpha_img": a_i, "alpha_bbb": a_b, "alpha_txt": a_t, "alpha_txt_bbb": a_bc,
"best_win_img_ms": [bi * 100, (bi + 1) * 100],
"best_win_txt_ms": [bt * 100, (bt + 1) * 100],
"r2_img_best_window": float(w_i[bi]),
"r2_txt_best_window": float(w_t[bt]),
"r2_bbb_best_window": float(w_b[bi]),
"r2_bbb_concept_best_window": float(w_bc[bt]),
"null_img_at_best_median": float(np.median(null_i[:, bi])),
"null_img_at_best_p95": float(np.percentile(null_i[:, bi], 95)),
"p_img": p_i, "p_txt": p_t,
"ratio_img_over_bbb": (float(w_i[bi]) / float(w_b[bi]))
if abs(w_b[bi]) > 1e-12 else None,
"per_window_r2_img": [float(x) for x in w_i],
"per_window_r2_bbb": [float(x) for x in w_b],
"elapsed_s": round(time.time() - t0, 1),
}
rows.append(rec)
log(f" sub-{sid:02d}: 最佳窗 {bi*100}-{bi*100+100}ms "
f"R² img {w_i[bi]:+.5f} (零中位 {rec['null_img_at_best_median']:+.5f}, p={p_i:.3f}) | "
f"BBB {w_b[bi]:+.5f} | img/BBB {rec['ratio_img_over_bbb']:.2f} | "
f"txt(概念级) {w_t[bt]:+.5f} (p={p_t:.3f}) [{rec['elapsed_s']}s]")
OUT_DIR.mkdir(parents=True, exist_ok=True)
(OUT_DIR / "exp69_train_encoding.json").write_text(
json.dumps({"per_subject": rows}, indent=2, ensure_ascii=False),
encoding="utf-8")
return {"per_subject": rows}
# ================================================================ 主
def main():
t0 = time.time()
OUT_DIR.mkdir(parents=True, exist_ok=True)
rng = np.random.default_rng(SEED)
report = {"schema_version": "1.0", "script": "exp69_train_encoding",
"started": time.strftime("%Y-%m-%dT%H:%M:%S"),
"design": {"n_images": 16540, "n_concepts": 1654, "images_per_concept": 10,
"ridge": "primal (n>>d)", "cv": "5-fold over IMAGES",
"null": "permuting training-fold Y rows (X fixed -> Gram reused)",
"baselines": ["置换零", "BBB 跨被试脑对脑上限"]}}
IMG, TXT, align = part0_align()
report["alignment"] = align
if not align["ok"]:
log("\n❌ 顺序对齐未通过 —— 终止")
report["verdicts"] = {"alignment": {"ok": False}}
(OUT_DIR / "exp69_train_encoding.json").write_text(
json.dumps(report, indent=2, ensure_ascii=False), encoding="utf-8")
return
tas = part1_targets()
report["encoding"] = part2_encode(IMG, TXT, tas, rng)
rows = report["encoding"].get("per_subject", [])
if rows:
med_img = float(np.median([r["r2_img_best_window"] for r in rows]))
med_bbb = float(np.median([r["r2_bbb_best_window"] for r in rows]))
med_txt = float(np.median([r["r2_txt_best_window"] for r in rows]))
med_null = float(np.median([r["null_img_at_best_median"] for r in rows]))
n_sig = sum(1 for r in rows if r["p_img"] < 0.05)
ratio = float(np.median([r["ratio_img_over_bbb"] for r in rows]))
log("\n" + "=" * 84)
log("判定")
log("=" * 84)
log(f" 编码显著 {'✅' if n_sig >= max(3, len(rows)//2) else '❌'} "
f"最佳窗 R² 中位 img {med_img:+.5f} vs 零 {med_null:+.5f},显著 {n_sig}/{len(rows)}")
log(f" vs BBB BBB {med_bbb:+.5f},img/BBB = {ratio:.3f}")
log(f" txt(概念级) 中位 {med_txt:+.5f}")
report["verdicts"] = {
"alignment": {"ok": True},
"encoding_significant": {"ok": bool(n_sig >= max(3, len(rows) // 2)),
"median_r2_img": med_img, "n_sig": n_sig,
"n_subjects": len(rows), "median_null": med_null},
"vs_bbb": {"ok": bool(ratio > 0), "median_r2_bbb": med_bbb, "ratio": ratio},
}
report["elapsed_s"] = round(time.time() - t0, 1)
(OUT_DIR / "exp69_train_encoding_full.json").write_text(
json.dumps(report, indent=2, ensure_ascii=False), encoding="utf-8")
print("=== JSON BEGIN ===")
print(json.dumps(report, indent=2, ensure_ascii=False))
print("=== JSON END ===")
log(f"\n报告 → {OUT_DIR}/exp69_train_encoding_full.json 耗时 {time.time()-t0:.0f}s")
if __name__ == "__main__":
main()

Xet Storage Details

Size:
15.8 kB
·
Xet hash:
6ee4b7a0f5b155e3eb83487200c743828c066d60fcb3e463a4f0aaffe991df2e

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