Buckets:

illusion-brain/brain-domains / scripts /exp80_eeg_tokenizer_train.py
redmingwei's picture
download
raw
18.6 kB
#!/usr/bin/env python3
"""exp80_eeg_tokenizer_train.py — S1:自训 EEG 离散 tokenizer(量化码本路线)。
## 为什么必须自己写一个(而不是复用某个基模)
exp75 的结论:**7 个基模里只有 braintokenizer 是「时间片级」token**——
它的输出是 (B, 19ch, 512 步) ⇒ 每个时间步一个码。
其余 6 个都是「**每通道一个 token、没有时间轴**」:
| 模型 | token 形状 | 时间轴? |
|---|---|---|
| cbramod | (B, 63, 200) = 63 个通道位置 | ❌ |
| signal-jepa | (B, 62, 64) | ❌ |
| mirepnet | (B, 61, 256) | ❌ |
| biot / eegdino / braintokenizer(量化码是 (B,19,512) ✅) | | |
⇒ 脑原生的 tokenizer 必须**每时间片一个 token**(Chameleon 的图像 VQ 那样),
否则没法做「原生交错序列」(脑段要是一串 token)。
## 架构(照 braintokenizer 的骨架,但自训)
```
EEG (B, 63ch, 250 样本 @250Hz)
├─ ch_mix Conv1d(63→64, k=1) 通道混合
├─ enc 2 × [Conv1d(64, k=5) + GELU + GroupNorm]
├─ t_down Conv1d(64→64, k=8, stride=8) 时间下采样 250 → 31 个时间片
├─ VQ 码本 (K=1024, d=64) 直线估计 + EMA 更新
└─ dec ConvTranspose1d(64, k=8, s=8) → Conv1d(64→63, k=1) → 重建波形
```
**训练目标 = 掩码重建**:随机遮住 50% 的时间片,decoder 只看**可见 token**
(被遮位置换成可学习的 mask embedding),重建整段波形。
⇒ 码本被迫编码**上下文相关**的信息,而不是逐点的信号幅度。
## 判据(预先写死,跑完只看这些)
1. **量化码的 RSA 显著**(p<0.05),且不低于 braintokenizer 的 +0.0155;
2. **码本利用率** > 50%(避免塌成位置编码);
3. **对照 A**:同一 encoder **不量化**(连续特征)的 RSA ⇒ 量化是否真的有用;
4. **对照 B**:**随机码本**(不训练)的 RSA ⇒ 「量化这个操作」本身有没有用。
## 数据
桶内 `/training/eeg/things-eeg/Preprocessed_data_250Hz_whiten/sub-XX/train.pt`
(**小写** things-eeg;已白化、250 Hz、63 通道 ⇒ 正好是我们的输入域;
实测 train.pt = (16540, 4, 63, 250) float16 ⇒ **真正的训练集**。
用 `torch.load(mmap=True)` 避免把 4.2 GB 的数组整个读进内存)。
⚠️ 顺带留一笔(**§十九 的一次自我纠错**,写在这里免得再犯):
我曾断言桶里 `omnibrain4d/eeg_train/` 装的是 **test split**
(把 66,160 算成了 132,320 —— **少除了 float16 的 2**)
⇒ 并据此推出「指标 B 家族泄漏」。
exp85 用**数值级溯源**(不靠算术)复核后的结论是:
它装的就是 **train split (16540, 4)**,而 `eeg/` 的 200 张**不在** train split 里
⇒ **指标 B 从来没有泄漏** ⇒ 基线表不需要重做。
⇒ 教训:**形状要交叉验证,别靠一次除法自证**。
## 用法
hf jobs run "hf.co/spaces/redmingwei/IllusionBrain" \
python -u /domains/scripts/exp80_eeg_tokenizer_train.py \
-v hf://buckets/illusion-brain/training-data:/training:ro \
-v hf://buckets/illusion-brain/brain-domains:/domains \
--flavor cpu-upgrade --timeout 180m -d --steps 4000 --out-dir /domains/runs/illusionbrain-align/exp80
"""
from __future__ import annotations
import argparse
import json
import math
import os
import sys
import time
import traceback
from pathlib import Path
import numpy as np
import torch
from torch import nn
import torch.nn.functional as F
for _v in ("OMP_NUM_THREADS", "OPENBLAS_NUM_THREADS", "MKL_NUM_THREADS"):
os.environ.setdefault(_v, "8")
EXP_DIR = Path(__file__).resolve().parent
# ⚠️ 桶里是**小写** `things-eeg`(写成 `things-EEG` 会列到空目录 ⇒ 静默取不到数据)
TRAIN_ROOT = Path("/training/eeg/things-eeg/Preprocessed_data_250Hz_whiten")
OUT_ROOT = Path("/domains/runs/illusionbrain-align/exp80")
SEED = 0
K_CODES = 1024
D_MODEL = 64
T_STRIDE = 8
def log(*a):
print(*a, flush=True)
# ---------------------------------------------------------------------------
# 数据
# ---------------------------------------------------------------------------
def open_train(subject):
"""打开 train.pt(mmap,避免 4.2 GB 全量进内存)。返回 (eeg数组, 取样函数)。"""
import torch
fp = TRAIN_ROOT / f"sub-{subject:02d}" / "train.pt"
if not fp.exists():
raise FileNotFoundError(str(fp))
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)
log(f" train.pt keys:{list(obj.keys()) if isinstance(obj, dict) else type(obj)}")
eeg = obj["eeg"] if isinstance(obj, dict) and "eeg" in obj else obj
log(f" eeg:{tuple(eeg.shape)} {getattr(eeg, 'dtype', None)}")
return obj, eeg
def sample_batch(eeg, rng, bs):
"""随机取 bs 个试次(eeg 的前两轴是 [n_img, n_rep])。
⚠️ `train.pt` 里的 `eeg` 是 **numpy 数组**(实测 keys: eeg/label/img/text/
session/ch_names/times)⇒ 直接 `torch.stack(numpy_rows)` 会报
「expected Tensor as element 0」⇒ 统一先 `torch.as_tensor`。
"""
import torch
n_img, n_rep = eeg.shape[0], eeg.shape[1]
flat_n = n_img * n_rep
idx = rng.integers(0, flat_n, size=bs)
rows = []
for i in idx:
im, rp = divmod(int(i), n_rep)
rows.append(torch.as_tensor(np.asarray(eeg[im, rp])))
x = torch.stack(rows).float()
return x # (bs, C, T)
# ---------------------------------------------------------------------------
# 模型
# ---------------------------------------------------------------------------
class VQ(nn.Module):
"""向量量化:直线估计 + EMA 码本更新(标准 VQ-VAE 做法)。"""
def __init__(self, n_codes=K_CODES, dim=D_MODEL, decay=0.99, eps=1e-5):
super().__init__()
self.n_codes, self.dim, self.decay, self.eps = n_codes, dim, decay, eps
self.embedding = nn.Embedding(n_codes, dim)
self.embedding.weight.data.uniform_(-1.0 / n_codes, 1.0 / n_codes)
self.register_buffer("ema", self.embedding.weight.data.clone())
self.register_buffer("cluster_size", torch.zeros(n_codes))
self.register_buffer("embed_avg", self.embedding.weight.data.clone())
def lookup(self, idx):
return self.embedding(idx)
def forward(self, z):
"""z: (B, T, D) → (z_q, loss, idx)"""
B, T, D = z.shape
flat = z.reshape(-1, D)
d = (flat.pow(2).sum(1, keepdim=True)
- 2 * flat @ self.embedding.weight.t()
+ self.embedding.weight.pow(2).sum(1))
idx = d.argmin(1)
zq = self.embedding(idx).view(B, T, D)
# EMA 更新
with torch.no_grad():
onehot = F.one_hot(idx, self.n_codes).type(flat.dtype)
self.cluster_size.mul_(self.decay).add_(onehot.sum(0), alpha=1 - self.decay)
self.embed_avg.mul_(self.decay).add_(onehot.t() @ flat, alpha=1 - self.decay)
n = self.cluster_size.sum()
cs = (self.cluster_size + self.eps) / (n + self.n_codes * self.eps) * n
self.embedding.weight.data.copy_(self.embed_avg / cs.unsqueeze(1))
self.ema.copy_(self.embedding.weight.data)
loss = ((zq.detach() - z) ** 2).mean() # commitment
zq = z + (zq - z).detach() # 直线估计
return zq, loss, idx.view(B, T)
@torch.no_grad()
def restart_dead(self, z, frac=0.05):
"""**死码重启**:把几乎没被用过的码重新初始化成当前批次的 z 样本。
为什么必须做:EMA 更新里,cluster_size≈0 的码其 embed_avg/cs 会放大成噪声
⇒ 那些码再也不被选中 ⇒ **永久死亡** ⇒ 码本利用率一路掉到个位数
(本地自测实测:初始化时 100%,4 步后 1.6%)。
重启把它们拉回「当前数据里真实存在的向量」⇒ 利用率能维持。
"""
flat = z.reshape(-1, self.dim)
thresh = max(1.0, frac * self.cluster_size.sum() / self.n_codes)
dead = (self.cluster_size < thresh).nonzero(as_tuple=True)[0]
if dead.numel() == 0:
return 0
pick = torch.randint(0, flat.shape[0], (dead.numel(),), device=flat.device)
self.embedding.weight.data[dead] = flat[pick]
self.embed_avg[dead] = flat[pick]
self.cluster_size[dead] = 1.0
self.ema[dead] = flat[pick]
return int(dead.numel())
@torch.no_grad()
def init_from_data(self, z):
"""用**第一批数据**初始化码本(VQ-VAE 经典做法)。
为什么必须做:随机初始化的码本在第一步会被「最远的那几个码」吃掉
⇒ 立刻塌成 1~2 个码(本地自测实测利用率掉到 1.6%、熵≈0)
⇒ 后面 EMA 再怎么更新也救不回来(死码的 cluster_size 永远是 0)。
"""
flat = z.reshape(-1, self.dim)
n = flat.shape[0]
if n < self.n_codes:
flat = flat.repeat((self.n_codes // n) + 1, 1)[:self.n_codes]
perm = torch.randperm(flat.shape[0], device=flat.device)[:self.n_codes]
self.embedding.weight.data.copy_(flat[perm])
self.embed_avg.copy_(self.embedding.weight.data)
self.cluster_size.fill_(1.0)
self.ema.copy_(self.embedding.weight.data)
return self.embedding.weight.data.clone()
@torch.no_grad()
def usage(self, idx):
cnt = torch.bincount(idx.reshape(-1), minlength=self.n_codes).float()
used = int((cnt > 0).sum())
p = cnt / cnt.sum().clamp(min=1)
nz = p[p > 0]
ent = float(-(nz * nz.log()).sum())
return {"used": used, "total": self.n_codes,
"utilization": used / self.n_codes,
"entropy_nats": ent,
"entropy_max": float(math.log(max(used, 2)))}
class MaskEmbed(nn.Module):
"""被遮时间片的占位 embedding(decoder 侧)。"""
def __init__(self, dim=D_MODEL):
super().__init__()
self.emb = nn.Parameter(torch.zeros(1, 1, dim))
nn.init.normal_(self.emb, std=0.02)
def forward(self, x, mask):
"""x: (B,T,D);mask: (B,T) bool,True = 被遮。"""
return torch.where(mask.unsqueeze(-1), self.emb.expand_as(x), x)
class EEGTokenizer(nn.Module):
def __init__(self, n_ch=63, n_times=250, n_codes=K_CODES, dim=D_MODEL):
super().__init__()
self.ch_mix = nn.Conv1d(n_ch, dim, 1)
self.enc = nn.Sequential(
nn.Conv1d(dim, dim, 5, padding=2), nn.GELU(), nn.GroupNorm(8, dim),
nn.Conv1d(dim, dim, 5, padding=2), nn.GELU(), nn.GroupNorm(8, dim))
self.t_down = nn.Conv1d(dim, dim, T_STRIDE, stride=T_STRIDE)
self.vq = VQ(n_codes, dim)
self.mask_emb = MaskEmbed(dim)
self.dec_up = nn.ConvTranspose1d(dim, dim, T_STRIDE, stride=T_STRIDE)
self.dec_out = nn.Conv1d(dim, n_ch, 1)
self.n_times = n_times
def encode(self, x):
h = self.ch_mix(x)
h = self.enc(h)
h = self.t_down(h) # (B, D, T')
return h.transpose(1, 2) # (B, T', D)
def decode(self, zq, n_times):
h = zq.transpose(1, 2) # (B, D, T')
h = self.dec_up(h) # (B, D, T'*8)
h = F.pad(h, (0, max(0, n_times - h.shape[-1])))[:, :, :n_times]
return self.dec_out(h) # (B, C, T)
def forward(self, x, mask_ratio=0.5, generator=None):
z = self.encode(x) # (B, T', D)
zq, vq_loss, idx = self.vq(z)
# 掩码:随机选 mask_ratio 的时间片
B, T, D = z.shape
if mask_ratio > 0:
noise = torch.rand(B, T, device=z.device, generator=generator)
thresh = torch.quantile(noise, mask_ratio, dim=1, keepdim=True)
mask = noise <= thresh
else:
mask = torch.zeros(B, T, dtype=torch.bool, device=z.device)
zq_in = self.mask_emb(zq, mask)
rec = self.decode(zq_in, x.shape[-1])
return rec, vq_loss, idx, mask, z
# ---------------------------------------------------------------------------
# 训练
# ---------------------------------------------------------------------------
def train(args):
torch.manual_seed(SEED)
rng = np.random.default_rng(SEED)
out_dir = Path(args.out_dir)
out_dir.mkdir(parents=True, exist_ok=True)
log(f"=== S1:自训 EEG 离散 tokenizer ===")
log(f"步数 {args.steps} | batch {args.batch} | 码本 K={args.codes} d={D_MODEL} "
f"| 掩码率 {args.mask_ratio} | 训练被试 {args.subject}")
if getattr(args, "self_test", False):
log(" [自测] 用**有结构**的合成数据代替真实 train.pt")
# 4 个固定模式 + 噪声 ⇒ 编码器应该学会区分它们
#(纯随机噪声的「正确最优」就是常数 ⇒ 必然塌陷,自测没有信息量)
pat = torch.randn(4, 63, 250) * 1.0
eeg = pat[torch.randint(0, 4, (64, 4))] + torch.randn(64, 4, 63, 250) * 0.3
else:
obj, eeg = open_train(args.subject)
model = EEGTokenizer(n_codes=args.codes)
nparam = sum(p.numel() for p in model.parameters())
log(f"模型参数量 {nparam:,}")
opt = torch.optim.AdamW([
{"params": list(model.ch_mix.parameters()) + list(model.enc.parameters())
+ list(model.t_down.parameters()) + list(model.dec_up.parameters())
+ list(model.dec_out.parameters()) + list(model.mask_emb.parameters()),
"lr": args.lr},
], weight_decay=1e-4)
t0 = time.time()
hist = []
for step in range(1, args.steps + 1):
x = sample_batch(eeg, rng, args.batch)
if step == 1:
with torch.no_grad():
z0 = model.encode(x)
model.vq.init_from_data(z0)
log(f" 码本已用第一批数据初始化({args.codes} 个码)")
rec, vq_loss, idx, mask, z = model(x, mask_ratio=args.mask_ratio)
if step % args.restart_every == 0:
with torch.no_grad():
ndead = model.vq.restart_dead(z.detach())
if ndead:
log(f" step {step:>5} | 死码重启 {ndead} 个")
rec_loss = F.mse_loss(rec, x)
loss = rec_loss + args.vq_weight * vq_loss
opt.zero_grad(set_to_none=True)
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
opt.step()
if step % args.log_every == 0 or step == 1:
with torch.no_grad():
u = model.vq.usage(idx)
zstd = float(z.detach().reshape(-1, z.shape[-1]).std(0).mean())
# ⚠️ **被遮位置的 MSE**才是与 loss 同口径的量:
# loss = 全波形 MSE,而被遮位置才是难的那部分。
# 基线也要同口径(「时间轴均值」在被遮位置上的 MSE),
# 否则「重建 MSE vs 基线」根本不能比(实测踩过:
# 全波形基线 0.54 vs 含被遮位置的 loss 0.94 ⇒ 假象)。
m = mask.unsqueeze(1)
rec_mse_masked = float(((rec - x) ** 2)[m].mean())
base_mse_masked = float(
((x.mean(dim=2, keepdim=True).expand_as(x) - x) ** 2)[m].mean())
rec_mse = float(rec_loss.item())
base_mse = float(F.mse_loss(
x, x.mean(dim=2, keepdim=True).expand_as(x)).item())
hist.append({"step": step, "loss": float(loss.item()),
"rec_mse": rec_mse, "base_mse": base_mse,
"rec_mse_masked": rec_mse_masked,
"base_mse_masked": base_mse_masked,
"vq_loss": float(vq_loss.item()),
"utilization": u["utilization"],
"entropy": u["entropy_nats"], "z_std": zstd})
log(f" step {step:>5} | loss {float(loss.item()):.4f} "
f"| 全波形 MSE {rec_mse:.4f}(基线 {base_mse:.4f}) "
f"| **被遮位置 MSE {rec_mse_masked:.4f}"
f"(基线 {base_mse_masked:.4f})** "
f"| vq {float(vq_loss.item()):.4f} | z_std {zstd:.4f} "
f"| 码利用率 {u['utilization']:.1%} 熵 {u['entropy_nats']:.2f}"
f" | {time.time()-t0:.0f}s")
if args.max_seconds and (time.time() - t0) > args.max_seconds:
log(f" 到达时间上限 {args.max_seconds}s,提前收尾")
break
# --- 保存 ---
ckpt = {"state_dict": model.state_dict(), "args": vars(args),
"n_codes": args.codes, "d_model": D_MODEL,
"n_tokens": (250 + T_STRIDE - 1) // T_STRIDE,
"hist": hist}
fp = out_dir / f"tokenizer_sub-{args.subject:02d}.pt"
torch.save(ckpt, fp)
log(f"权重写入 {fp}({fp.stat().st_size/1e6:.1f} MB)")
(out_dir / f"train_log_sub-{args.subject:02d}.json").write_text(
json.dumps({"args": vars(args), "hist": hist}, indent=2,
ensure_ascii=False, default=str), encoding="utf-8")
log(f"总耗时 {time.time()-t0:.0f}s")
return 0
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--subject", type=int, default=1, help="用哪个被试的 train 集训练")
ap.add_argument("--steps", type=int, default=4000)
ap.add_argument("--batch", type=int, default=64)
ap.add_argument("--lr", type=float, default=1e-3)
ap.add_argument("--codes", type=int, default=K_CODES)
ap.add_argument("--mask-ratio", type=float, default=0.5)
ap.add_argument("--vq-weight", type=float, default=0.25)
ap.add_argument("--log-every", type=int, default=50)
ap.add_argument("--restart-every", type=int, default=20,
help="每多少步做一次死码重启")
ap.add_argument("--max-seconds", type=int, default=0, help="时间上限(秒),0=不限")
ap.add_argument("--out-dir", default=str(OUT_ROOT))
ap.add_argument("--self-test", action="store_true",
help="用合成数据把训练循环跑通(不碰网络)")
args = ap.parse_args()
try:
raise SystemExit(train(args))
except SystemExit:
raise
except Exception as e:
log(f"✗ {type(e).__name__}: {e}")
log(traceback.format_exc()[-1500:])
raise SystemExit(1)
if __name__ == "__main__":
main()

Xet Storage Details

Size:
18.6 kB
·
Xet hash:
5e7b85f4492dd34cad189c4c02796dc40c030d6d5d84271419268feb192dd144

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