Buckets:

illusion-brain/brain-domains / scripts /exp81_tokenizer_eval.py
redmingwei's picture
download
raw
23.5 kB
#!/usr/bin/env python3
"""exp81_tokenizer_eval.py — S1:自训 EEG 离散 tokenizer 的评测(**干净协议**)。
## 口径纪律(这一版和基线表的关系,已按 §十九 的结论更正)
⚠️ **这里曾写错过,保留说明以免再犯**:本节原来断言
「`/domains/omnibrain4d/eeg_train/` 装的是 test split ⇒ 指标 B 泄漏」——
那是**一次除法算错**造成的(把 66,160 写成 132,320,少除了 float16 的 2)。
exp85 用**数值级溯源**(不靠算术)复核后的结论是:
| 目录 | 实际内容 | 证据 |
|---|---|---|
| `/domains/omnibrain4d/eeg_train/sub-XX.npz` | **train split (16540, 4)** | 2,084,040,260 ÷ (63×250×2) = 66,160 = 16540×4 |
| `/domains/omnibrain4d/eeg/sub-XX.npz` | **test split (200, 80)** | 200 张**不在** train split 里 |
⇒ 拟合集(train split)与解码集(test split)**本就不重叠 ⇒ 指标 B 从来没有泄漏**
⇒ **基线表与阶段 D 变体表的指标 B 不需要重做**,
⇒ 本脚本的指标 B 数字**可以**和基线表直接比(口径相同)。
本脚本仍然用**直接的 train split**(`train.pt`)当拟合集,而不是 npz:
少一层目录名的坑(`eeg_train/` 这个名字本身就是误导源)。
旁证(exp84):用真正 train split 重算 630-d 那一行得 **acc1 = 0.0111(2.3× 随机)**,
与基线表原协议的 **0.0111(2.2×)** 几乎一样 ⇒ 两个协议本来就都是干净的。
| | 拟合 | 解码 |
|---|---|---|
| 指标 A(RSA) | —(不训练) | test split 200 图 × 80 试次(与基线表同口径) |
| 指标 B(ridge 200 选 1) | **train split**(`train.pt` 前 N 图 × 4 试次,16540 张里取) | test split 200 图 |
## 评测对象与读出
自训 tokenizer 产出**每时间片一个 token**(31 个),与 braintokenizer 的
「19 位置 × 512 时间步」布局不同 ⇒ 读出必须重新定义:
| 读出 | 构造 | 维度 |
|---|---|---|
| `emb_mean` | 码本向量按 token 平均 | d=64(**主读出**) |
| `onehot_mean` | 每个 token 的 one-hot 码按 token 平均 | K=1024(纯离散) |
| `code_ids` | 31 个码号本身 | 31(码号顺序是任意的 ⇒ 预期无意义,作负对照) |
| `continuous` | **不量化**,同一 encoder 的特征按 token 平均 | 64(**对照 A**) |
| `random_codebook` | 同一 encoder + **随机码本**量化 | 64(**对照 B**) |
## 判据(预先写死)
1. `emb_mean` 的指标 A 显著(p<0.05),且不低于 braintokenizer 的 +0.0155;
2. 码本利用率 > 50%;
3. 指标 B(干净协议)高于随机;
4. `emb_mean` > `continuous`(量化有用)与 `emb_mean` > `random_codebook`(要靠训练)。
## 用法
hf jobs run "hf.co/spaces/redmingwei/IllusionBrain" \
python -u /domains/scripts/exp81_tokenizer_eval.py \
-v hf://buckets/illusion-brain/training-data:/training:ro \
-v hf://buckets/illusion-brain/brain-domains:/domains \
--flavor cpu-upgrade --timeout 90m -d \
--ckpt /domains/runs/illusionbrain-align/exp80/tokenizer_sub-01.pt
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import time
import traceback
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")
EXP_DIR = Path(__file__).resolve().parent
_PKG_CANDIDATES = [EXP_DIR / "models", EXP_DIR.parent / "models", EXP_DIR]
PKG_DIR = next((c for c in _PKG_CANDIDATES if (c / "registry.py").exists()),
_PKG_CANDIDATES[0])
for _p in (str(EXP_DIR), str(PKG_DIR)):
if _p not in sys.path:
sys.path.insert(0, _p)
import eval_brain_alignment as EVAL # noqa: E402
TRAIN_PT = Path("/training/eeg/things-eeg/Preprocessed_data_250Hz_whiten")
TEST_4D = Path("/domains/omnibrain4d/eeg")
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/illusionbrain-align/exp81")
SEED = 0
READOUTS = ("recon", "emb_mean", "onehot_mean", "code_ids", "continuous",
"random_codebook")
def log(*a):
print(*a, flush=True)
def load_model(ckpt_path, seed=SEED):
"""加载自训 tokenizer + 造一个随机码本(对照 B)。
⚠️ **按 checkpoint 里的元信息自动选架构**:
- `arch == "base"` ⇒ exp80 的 EEGTokenizer(S1a)
- `arch == "assembled"` ⇒ exp86 的组装模型(组件级组装的产物)
⇒ 两种产物共用同一个评测脚本、同一套读出口径、同一份判据。
"""
import torch
ck = torch.load(ckpt_path, map_location="cpu", weights_only=False)
arch = ck.get("arch", "base")
if arch.startswith("assembled"):
from exp86_assembled_train import (build_assembled, extract_attn_state,
extract_ffn_donor)
a = ck.get("args", {})
attn_name = a.get("attn_donor", "signal-jepa")
# ⚠️ `attn_donor == "none"` ⇒ 那个 checkpoint **没有**注意力块
# (同配方的「不嫁接注意力」对照臂)⇒ 不能去读供体文件
attn = None if attn_name == "none" else extract_attn_state(attn_name)
ffn = None
if ck.get("donor_ffn", "none") != "none":
ffn = extract_ffn_donor(ck["donor_ffn"])
heads = "v2" if arch in ("assembled_v2", "assembled_v3") else "v1"
# ⚠️ `assembled_v3`(S1b-v3 共享词典)用的是**梯度版码本** `VQShared`
# ⇒ 评测时必须用同一个类重建,否则 state_dict 键位/结构对不上。
# (键名与 exp80 的 VQ 一致,但类不同 ⇒ 不能只靠键名自动兼容)
vq_factory = None
if arch == "assembled_v3" and ck.get("vq") == "grad":
from exp87_shared_vocab import build_vqshared
a = ck.get("args", {})
vq_factory = build_vqshared(
balance=(a.get("balance", "none") == "sinkhorn"),
eps_bal=float(a.get("eps_bal", 0.05)))
model = build_assembled(n_codes=int(ck["n_codes"]), attn_state=attn,
ffn_donor=ffn, heads=heads, vq_factory=vq_factory)
log(f" 架构:{arch}(注意力 {attn_name} / "
f"FFN {ck.get('donor_ffn')} / 码本 {ck.get('vq', 'ema')})")
else:
from exp80_eeg_tokenizer_train import EEGTokenizer
model = EEGTokenizer(n_codes=int(ck["n_codes"]))
log(" 架构:base")
model.load_state_dict(ck["state_dict"])
model.eval()
g = torch.Generator().manual_seed(seed)
rnd = torch.randn(int(ck["n_codes"]), model.vq.dim, generator=g) * 0.5
return model, ck, rnd
def readouts_for(model, x, rnd_codebook):
"""一批 EEG → 各读出的池化特征。"""
import torch
import torch.nn.functional as F
with torch.no_grad():
z = model.encode(x) # (B, T', D)
zq, _loss, idx = model.vq(z) # zq: (B,T',D)
codes = idx # (B, T')
K = model.vq.n_codes
# 对照 B:随机码本
flat = z.reshape(-1, z.shape[-1])
d = (flat.pow(2).sum(1, keepdim=True) - 2 * flat @ rnd_codebook.t()
+ rnd_codebook.pow(2).sum(1))
r_idx = d.argmin(1).view(codes.shape)
# 随机码本对应的「嵌入」就是随机码本向量本身
r_emb = rnd_codebook[r_idx] # (B,T',D)
emb = model.vq.lookup(codes) # (B,T',D)
onehot = F.one_hot(codes, K).float() # (B,T',K)
# 重建层:解码器重建的 EEG(**与 braintokenizer 那一行同口径**:
# 它的最终输出就是重建信号 (B,19,512),沿通道轴池化 ⇒ (B,512))
# ⚠️ `model.decode` 内部**已经**做了一次 transpose ⇒ 这里不能再转
# (转两次会得到 (B,T',D) ⇒ ConvTranspose1d 报「31 channels instead of 64」)
rec = model.decode(zq, x.shape[-1]) # (B, C, T)
out = {
"emb_mean": emb.mean(dim=1), # (B, D)
"onehot_mean": onehot.mean(dim=1), # (B, K)
"code_ids": codes.float(), # (B, T')
"continuous": z.mean(dim=1), # (B, D)
"random_codebook": r_emb.mean(dim=1), # (B, D)
"recon": rec.mean(dim=1), # (B, T)
}
return out
def extract(model, eeg, rnd_cb, batch_size=64, device="cpu"):
"""(n_img, n_rep, C, T) → ({readout: (n_img, n_rep, D)}, 码号直方图)"""
import torch
n_img, n_rep = eeg.shape[0], eeg.shape[1]
flat = eeg.reshape(n_img * n_rep, eeg.shape[2], eeg.shape[3])
acc = {k: [] for k in READOUTS}
K = model.vq.n_codes
hist = np.zeros(K, dtype=np.int64)
for s in range(0, flat.shape[0], batch_size):
sub = flat[s:s + batch_size]
x = torch.from_numpy(np.ascontiguousarray(sub)).float().to(device)
o = readouts_for(model, x, rnd_cb)
for k in READOUTS:
acc[k].append(o[k].cpu().numpy())
# 码号直方图(**整个 test 集**,不是抽几张 ⇒ 利用率才可信)
codes = o["code_ids"].round().long().cpu().numpy().reshape(-1)
for v, c_ in zip(*np.unique(codes, return_counts=True)):
if v < K:
hist[v] += int(c_)
feats = {k: np.concatenate(v, 0).reshape(n_img, n_rep, -1) for k, v in acc.items()}
return feats, hist
def usage_from_hist(hist):
"""码本利用率 + 熵(**用整个 test 集的码号直方图**)。"""
cnt = hist[hist > 0]
total = max(int(hist.sum()), 1)
p = cnt / total
ent = float(-(p * np.log(p)).sum())
return {"used": int(len(cnt)), "total": int(len(hist)),
"utilization": len(cnt) / max(len(hist), 1),
"entropy_nats": ent,
"entropy_max": float(np.log(max(len(cnt), 2)))}
def load_train_split(subject, n_images):
"""train split 的前 n_images 张(每张 n_rep 次)—— 指标 B 的**拟合集**。
⚠️ `train.pt` 里的 eeg 可能是 **numpy 数组**(实测)⇒ 用 `np.asarray` 统一,
别直接 `.numpy()`(numpy 数组没有那个方法)。
"""
import torch
fp = TRAIN_PT / f"sub-{subject:02d}" / "train.pt"
try:
obj = torch.load(fp, map_location="cpu", mmap=True, weights_only=False)
except TypeError:
obj = torch.load(fp, map_location="cpu", weights_only=False)
eeg = obj["eeg"]
log(f" train split:{tuple(eeg.shape)}")
return np.asarray(eeg[:n_images], dtype=np.float32)
def eval_subject(model, rnd_cb, eeg_te, eeg_tr, IMG_test, IMG_train, args, rng):
"""一个被试的完整评测。
`eeg_tr is None` ⇒ 只算指标 A(省掉 train split 的抽取与 ridge)。
返回 {"rows": {...}, "codebook": {...}}。
"""
rec = {"rows": {}}
F_te, hist = extract(model, eeg_te, rnd_cb, args.batch_size, args.device)
F_tr = None
if eeg_tr is not None:
F_tr, _h2 = extract(model, eeg_tr, rnd_cb, args.batch_size, args.device)
for k in READOUTS:
Xte = F_te[k]
# ⚠️ `F_tr` 在只算指标 A 时是 **None** ⇒ 不能无条件索引
# (上一版这么写 ⇒ 10 个被试全废在 TypeError 上)
Xtr = F_tr[k] if F_tr is not None else None
a = EVAL.metric_a_rsa(Xte, IMG_test, args.n_perm, rng)
entry = {"dim": int(Xte.shape[-1]),
"r_img": a["r_img"], "p_img": a["p_img"]}
if Xtr is not None and IMG_train is not None:
mu, sd = EVAL.zscore_fit(Xtr)
rb = EVAL.metric_b_decode(
EVAL.zscore_apply(Xtr, mu, sd), EVAL.zscore_apply(Xte, mu, sd),
IMG_train[:args.train_images], IMG_test, rng,
n_null=args.n_null)
entry["acc1"] = rb["acc1_cos"]
entry["null_acc1"] = float(np.median(rb["null_acc1_cos"]))
rec["rows"][k] = entry
btxt = (f"| B acc1={entry['acc1']:.4f} "
f"(零中位 {entry['null_acc1']:.4f})"
if "acc1" in entry else "| B 跳过")
log(f" {k:<18} dim={Xte.shape[-1]:<5} A r={a['r_img']:+.4f} "
f"p={a['p_img']:.3f} {btxt}")
rec["codebook"] = usage_from_hist(hist)
u = rec["codebook"]
log(f" 码本利用率:{u['used']}/{u['total']} = {u['utilization']:.1%} "
f"| 熵 {u['entropy_nats']:.2f} nats")
return rec
def self_test_assembled():
"""**组装架构**(v1 / v2 两种头)的加载与读出链路。
为什么必须测:这一轮要跑 **9 个评测 Job**(v1 四臂 + v2 四臂 + 对照臂),
而 `load_model` 对组装架构走的是**另一条分支**(`arch="assembled*"`)——
供体组件读取、头的数量(v1 两个 / v2 四个)、checkpoint 键位、读出维度
全都在这条分支上。
⇒ 一条没本地跑过的分支 = 9 个 Job 一起报废(真发生过:10 个被试全废在
`--no-metric-b` 的 TypeError 上)。
做法:造**合成供体**(MHA 64 的注意力 + 64→128→64 的 FFN),
分别按 `assembled` / `assembled_v2` 存两个 checkpoint,
再走**真实的 `load_model` 路径**加载 + 跑全部读出。
"""
import tempfile
import torch
from torch import nn
import exp86_assembled_train as E86
log("--- 自测:组装架构(assembled / assembled_v2)---")
with tempfile.TemporaryDirectory(prefix="exp81-assembled-") as td:
tmp = Path(td)
mha = nn.MultiheadAttention(64, 4, batch_first=True)
torch.save({"attn": {"state": mha.state_dict(), "src": "synthetic"}},
tmp / "signal-jepa.pt")
torch.save({"ffn": {"h": 128, "d": 64,
"w1": torch.randn(128, 64) * 0.05,
"b1": torch.zeros(128),
"w2": torch.randn(64, 128) * 0.05,
"b2": torch.zeros(64),
"src": "synthetic"}},
tmp / "mirepnet.pt")
old_dir = E86.DONOR_DIR
E86.DONOR_DIR = tmp
try:
for arch in ("assembled", "assembled_v2"):
heads = "v2" if arch == "assembled_v2" else "v1"
model = E86.build_assembled(
n_codes=64,
attn_state=E86.extract_attn_state("signal-jepa"),
ffn_donor=E86.extract_ffn_donor("mirepnet"),
heads=heads)
ck = {"state_dict": model.state_dict(),
"args": {"attn_donor": "signal-jepa", "steps": 1},
"n_codes": 64, "d_model": 64, "arch": arch,
"donor_ffn": "mirepnet", "hist": []}
fp = tmp / f"ck_{arch}.pt"
torch.save(ck, fp)
# ↓ 走**真实**的加载路径(不是手工搭模型)
m2, ck2, rnd = load_model(fp)
assert m2.vq.n_codes == 64, m2.vq.n_codes
x = torch.randn(6, 63, 250)
out = readouts_for(m2, x, rnd)
for k in READOUTS:
assert k in out, f"{arch} 缺读出 {k}"
assert torch.isfinite(out[k]).all(), f"{arch} 的 {k} 有非有限值"
assert out["onehot_mean"].shape[-1] == 64
assert out["emb_mean"].shape[-1] == 64
assert out["continuous"].shape[-1] == 64
assert out["recon"].shape[-1] == 250
log(f" ✓ {arch}:加载 + {len(READOUTS)} 个读出全部通过"
f"(头 {heads})")
# 「不嫁接注意力」的对照臂:checkpoint 里**没有**注意力块
model = E86.build_assembled(n_codes=64, attn_state=None,
ffn_donor=None, heads="v2")
assert not any(k.startswith(("attn.", "norm1."))
for k in model.state_dict()), "不该有注意力参数"
ck = {"state_dict": model.state_dict(),
"args": {"attn_donor": "none", "steps": 1},
"n_codes": 64, "d_model": 64, "arch": "assembled_v2",
"donor_ffn": "none", "hist": []}
fp = tmp / "ck_noattn.pt"
torch.save(ck, fp)
m2, _, rnd = load_model(fp)
out = readouts_for(m2, torch.randn(6, 63, 250), rnd)
for k in READOUTS:
assert k in out and torch.isfinite(out[k]).all(), k
log(" ✓ 不嫁接注意力(attn_donor=none):加载 + 读出全部通过")
finally:
E86.DONOR_DIR = old_dir
log("--- 组装架构自测通过 ---")
def self_test():
"""用**合成数据**把两条分支(算 B / 不算 B)都跑通。
为什么必须本地自测:上一版改了 `--no-metric-b` 分支后**没本地跑**就发到云端
⇒ 10 个被试全废在 TypeError 上(`F_tr` 为 None 仍被索引)。
"""
import torch
from exp80_eeg_tokenizer_train import EEGTokenizer
log("=== 自测:S1 评测链路(合成数据)===")
rng = np.random.default_rng(5)
n_img_te, n_img_tr, n_rep, d_img = 30, 40, 2, 32
IMG_te = rng.normal(size=(n_img_te, d_img))
IMG_tr = rng.normal(size=(n_img_tr, d_img))
W = rng.normal(size=(d_img, 63 * 250)) * 0.05
def make(IMG, n_rep):
e = (IMG @ W + 0.25 * rng.normal(size=(IMG.shape[0], 63 * 250)))
e = e.reshape(IMG.shape[0], 63, 250)
return np.repeat(e[:, None], n_rep, axis=1).astype(np.float32)
eeg_te, eeg_tr = make(IMG_te, n_rep), make(IMG_tr, n_rep)
model = EEGTokenizer(n_codes=64)
rnd_cb = torch.randn(64, model.vq.dim) * 0.5
for no_b in (True, False):
args = argparse.Namespace(train_images=n_img_tr, n_perm=20, n_null=1,
batch_size=8, device="cpu", no_metric_b=no_b)
rec = eval_subject(model, rnd_cb, eeg_te,
None if no_b else eeg_tr,
IMG_te, None if no_b else IMG_tr,
args, np.random.default_rng(1))
assert set(rec["rows"]) == set(READOUTS), rec["rows"].keys()
assert all(v["r_img"] == v["r_img"] for v in rec["rows"].values())
if no_b:
assert all("acc1" not in v for v in rec["rows"].values())
else:
assert all("acc1" in v for v in rec["rows"].values())
log(f" ✓ no_metric_b={no_b} 分支通过({len(rec['rows'])} 个读出)")
self_test_assembled()
log("=== 自测通过 ===")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--ckpt", default="",
help="自训 tokenizer 的 checkpoint(桶内路径);"
"--self-test 时不需要")
ap.add_argument("--subjects", default="1,2,3,4,5,6,7,8,9,10")
ap.add_argument("--train-images", type=int, default=2000,
help="指标 B 的拟合集用多少张 train split 的图")
ap.add_argument("--n-perm", type=int, default=200)
ap.add_argument("--n-null", type=int, default=8)
ap.add_argument("--batch-size", type=int, default=64)
ap.add_argument("--device", default="cpu")
ap.add_argument("--no-metric-b", action="store_true",
help="只算指标 A(RSA)。指标 B 的 ridge 很贵(试次级 Gram "
"8000² × 每读出 1+N 次拟合)而它对所有读出都在随机水平 "
"⇒ 主轮只跑 A,B 单独一轮补")
ap.add_argument("--out-dir", default=str(OUT_DIR))
ap.add_argument("--self-test", action="store_true",
help="合成数据把评测链路跑通(含 --no-metric-b 两条分支)")
args = ap.parse_args()
if args.self_test:
self_test()
return
if not args.ckpt:
log("✗ 需要 --ckpt(或用 --self-test)")
raise SystemExit(1)
import torch
out_dir = Path(args.out_dir)
out_dir.mkdir(parents=True, exist_ok=True)
ftr = torch.load(FEAT_TRAIN, map_location="cpu", weights_only=False)
IMG_train = ftr["img_features"].numpy().astype(np.float64)
fte = torch.load(FEAT_TEST, map_location="cpu", weights_only=False)
IMG_test = fte["img_features"].numpy().astype(np.float64)
log(f"图像特征:train {IMG_train.shape} / test {IMG_test.shape}")
model, ck, rnd_cb = load_model(args.ckpt)
log(f"✓ 自训 tokenizer:{args.ckpt} | 码本 K={model.vq.n_codes} "
f"d={model.vq.dim} | token 数 {ck.get('n_tokens')} | "
f"训练步数 {ck.get('args', {}).get('steps')}")
sids = [int(s) for s in args.subjects.split(",") if s.strip()]
rng = np.random.default_rng(SEED)
per_subject = []
for sid in sids:
t0 = time.time()
log(f"\n=== sub-{sid:02d} ===")
rec = {"subject": f"sub-{sid:02d}", "rows": {}}
try:
eeg_te = np.load(TEST_4D / f"sub-{sid:02d}.npz")["eeg"]
eeg_tr = None
if not args.no_metric_b:
eeg_tr = load_train_split(sid, args.train_images)
rec.update(eval_subject(model, rnd_cb, eeg_te, eeg_tr,
IMG_test, IMG_train, args, rng))
del eeg_te
except Exception as e:
rec["error"] = f"{type(e).__name__}: {e}"[:300]
rec["trace"] = traceback.format_exc()[-600:]
log(f" ✗ {rec['error']}")
rec["elapsed_s"] = round(time.time() - t0, 1)
per_subject.append(rec)
# ---- 汇总(中位 + 显著被试数 + 配对对照)----
summary = {}
for k in READOUTS:
rs = [r["rows"][k]["r_img"] for r in per_subject if k in r.get("rows", {})]
ps = [r["rows"][k]["p_img"] for r in per_subject if k in r.get("rows", {})]
ac = [r["rows"][k].get("acc1") for r in per_subject if k in r.get("rows", {})]
nu = [r["rows"][k].get("null_acc1") for r in per_subject
if k in r.get("rows", {})]
ac = [x for x in ac if x is not None]
nu = [x for x in nu if x is not None]
if not rs:
continue
rs_s = sorted(rs)
entry = {"n": len(rs), "r_median": rs_s[len(rs_s) // 2],
"p_median": sorted(ps)[len(ps) // 2],
"n_sig": sum(1 for p in ps if p < 0.05)}
if ac:
entry["acc1_median"] = sorted(ac)[len(ac) // 2]
entry["null_median"] = sorted(nu)[len(nu) // 2]
summary[k] = entry
log(f" {k:<18} 中位 A r={entry['r_median']:+.4f} "
f"({entry['n_sig']}/{entry['n']} 显著)")
if ac:
log(f" {'':<18} B acc1={entry['acc1_median']:.4f} "
f"(零中位 {entry['null_median']:.4f})")
(out_dir / "tokenizer_eval.json").write_text(
json.dumps({"ckpt": args.ckpt, "config": vars(args),
"per_subject": per_subject, "summary": summary},
indent=2, ensure_ascii=False, default=str), encoding="utf-8")
log(f"\n结果写入 {out_dir}/tokenizer_eval.json")
if __name__ == "__main__":
main()

Xet Storage Details

Size:
23.5 kB
·
Xet hash:
541b58d95ccfd881e5eba221fd53692fe34a91e88cd158cad316a1b75ca49378

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