GPT 风格 Transformer 解码 EEG:为什么、怎么做、踩了什么坑
为什么用 Transformer 解码 EEG
EEG 解码的传统方法是 LDA + CSP:手工提取特征,再喂给线性分类器。这条路成熟但天花板明显——特征工程依赖领域知识,换一个范式就得重来。
深度学习跳过了特征工程:CNN 直接从原始信号学特征。但 CNN 有自己的局限——它的卷积核是局部的,对 EEG 这种时序信号的长程依赖建模能力弱。
Transformer 的自注意力机制天然擅长捕捉长程依赖。NLP 领域已经证明了这一点。问题是:把 NLP 的 Transformer 搬到 EEG 上,哪些设计要改?
答案是几乎全要改。
EEG 不是 NLP:三个关键差异
| NLP | EEG | |
|---|---|---|
| 输入 | 离散 token(词表索引) | 连续多通道时序(float32 数组) |
| 位置信息 | 词序(离散位置) | 采样时间点(连续,长度可变) |
| 预测目标 | 下一个词(自回归生成) | 整段信号属于哪类(分类) |
这三个差异决定了架构的每一个选择。
架构设计
flowchart TB
Raw["原始 EEG
(B, n_channels, n_times)"]
ConvEmbed["Conv1d Token Embedding
kernel × stride
→ (B, n_tokens, n_channels)"]
Proj["Linear Projection
→ (B, n_tokens, d_model)"]
Block["N × Transformer Block"]
BlockLN1["LayerNorm"]
MHA["Multi-Head Attention"]
QKV["Q/K/V Linear + RoPE"]
Mask["GPT: 因果掩码
BERT: 无掩码"]
OutProj["Output Projection"]
FFN["FFN (4× expansion, GELU)"]
BlockLN2["LayerNorm"]
HeadGPT["GPT 分类头
x[:, -1, :] → Linear → n_classes"]
HeadBERT["BERT 分类头
[CLS] → LayerNorm → Linear → n_classes"]
Raw --> ConvEmbed --> Proj --> Block
Block --> BlockLN1 --> QKV --> Mask --> MHA --> OutProj
OutProj -.residual.-> Block
Block --> BlockLN2 --> FFN -.residual.-> Block
Block -->|GPT| HeadGPT
Block -->|BERT| HeadBERT
style Raw fill:#fef2f2,stroke:#dc2626
style ConvEmbed fill:#fff7ed,stroke:#ea580c
style Proj fill:#fff7ed,stroke:#ea580c
style Block fill:#e8f4ff,stroke:#1f6feb
style Mask fill:#fdf4ff,stroke:#a21caf
style HeadGPT fill:#f0fdf4,stroke:#16a34a
style HeadBERT fill:#f0fdf4,stroke:#16a34a
1. Token Embedding:不用词表,用卷积
NLP 有词表,每个词查表得到 embedding。EEG 没有词表——输入是 (n_channels, n_times) 的连续信号。
做法:用 Conv1d(n_channels, n_channels, kernel, stride) 把时域信号切成 token 序列。每个 token 覆盖 kernel 个时间点,步长 stride,输出的序列长度就是 n_tokens = (n_times - kernel) // stride + 1。
1 | class _TokenEmbedding(nn.Module): |
为什么用 Conv1d 而不是固定分帧:信号处理里"分帧"是按固定窗口切信号、每帧取均值或原始采样点——这是不可学习的。Conv1d 做的事本质相同(kernel=窗口,stride=步长),但权重可学习:kernel 内每个时间点的贡献由训练决定,比分帧平均更有表达力。
kernel/stride 怎么选:如果用户不指定,自动算。目标是让 n_tokens ≈ 128,50% 重叠(kernel = 2 * stride):
1 | stride = max(1, n_times // target_tokens) |
128 个 token 是目标值(target_tokens 参数),实际 token 数取决于数据的时间长度。实验里设了 kernel=1, stride=1——每个时间点作为独立 token,106 个时间点产生 106 个 token,让 Transformer 在最大序列长度下工作。
2. RoPE:让位置编码外推
NLP 的位置编码有两种主流做法:正弦位置编码(Transformer 原版)和可学习位置编码(BERT)。前者基于三角函数,天然支持外推;后者是 lookup table,只能编码训练时见过的长度,推理遇到更长序列就废了。RoPE 和正弦位置编码一样支持外推,但它的编码方式是旋转而不是加偏置——这让 Q/K 内积自然包含相对位置信息,并且在小数据上比固定正弦更灵活。
RoPE(Rotary Positional Embedding)是更好的选择:它把位置信息编码成 Q/K 的旋转角度,天然支持长度外推。实际场景中,训练时模型见过最多 106 个 token(kernel=1, stride=1),推理时如果给更长的信号段,RoPE 依然可以计算——不需要像可学习位置编码那样预留最大长度。
1 | class _RotaryPositionalEmbedding(nn.Module): |
核心思想:位置 m 的 Q/K 不加偏置,而是旋转。_rotate_half 把特征维的相邻两个值当作一对,做 2D 旋转。内积 Q·K 自然包含相对位置信息 m-n,不需要显式的位置编码表。
3. 因果注意力:只看过去,不看未来
这是 GPT 风格 Transformer 的标志性设计。标准 BERT 式自注意力每个位置能看所有位置,但 EEG 解码如果用双向注意力,模型会"偷看"未来的脑电信号——这在需要逐时刻推理(每个时间点只能用过去信息预测)的场景下是不允许的。因果掩码保留了这条约束。
因果掩码:上三角为 -inf,softmax 后变成 0:
1 | class _CausalMask(nn.Module): |
在注意力分数上加这个掩码,位置 t 只能 attend 到 0…t。
4. Pre-LN + 残差:稳定训练
1 | class _DecoderBlock(nn.Module): |
Pre-LN(先 LayerNorm 再进子层)比 Post-LN(先子层再 LayerNorm)训练更稳定。这是 GPT-2 以后的标准做法。FFN 扩张比 4×,用 GELU 激活。
5. 分类头:取最后一个位置
GPT 没有 [CLS] token,那取哪个位置做分类?
取最后一个:x[:, -1, :]。因为因果注意力下,最后一个位置积累了前面所有位置的信息——它"看"了整段信号。
但这是唯一的选择吗?并不是。双向注意力场景下,BERT 的做法更自然:在序列开头插入一个可学习的 [CLS] token,让它 attend 到所有位置,分类器读 [CLS] 输出。
1 | class _EEGTransformerBert(nn.Module): |
两种方案后来做了对比实验(相同数据、相同的金字塔增强、相同正则化强度),具体见实验部分。
6. 长度自适应推理
推理时 EEG 长度可能和训练时不一样。因为用了 Conv1d token embedding + RoPE,模型天然支持任意长度输入(只要 n_times >= kernel)。如果推理时 token 数超过训练时,RoPE 的位置会外推,给一个警告:
1 | if n_tokens > self._train_n_tokens: |
实验里 kernel=1, stride=1,_train_n_tokens=106。推理时如果给了更长的时间段(比如 120 个时间点),会产生 120 个 token,RoPE 外推;给了更短的则没有外推问题。
7. 逐通道 z-score 归一化
EEG 通道间的幅度差异很大(额区 vs 枕区),不归一化的话梯度会被大信号通道主导。训练时计算每个通道的均值/标准差,推理时用同样的统计量:
1 | if self.normalize: |
均值和标准差随模型一起保存,推理时自动应用。
实验验证:在 MNE Sample ERP 上的完整对比
构建了一条完整的实验流水线来验证这些设计决策,源码在 exercises/transformer_eval/。
数据
MNE Sample 数据集(与上面 EEG 工具链博客相同的来源)的听视觉 ERP——2 分类:听觉(左/右耳)vs 视觉(左/右视野),144 trial/类,59 通道 EEG,-200ms ~ +424ms(106 时间点),5-fold StratifiedKFold CV。
Epoch 切分后用 bci 工具链的 EpochPage 即可看到 ERP 形态对比——具体示例见 工具链博客 §4。
- 2 分类:听觉(左右耳)vs 视觉(左右视野),144 trial/类
- 59 通道 EEG,-200ms ~ +424ms(106 个时间点)
- 5-fold StratifiedKFold 交叉验证
对比基线
1 | # CNN: 卷积核扫过 (ch, time) 空间 |
| 模型 | Accuracy |
|---|---|
| CNN(baseline) | 0.944 |
| LDA | 0.910 |
| Transformer(因果+最后位置,无增强) | 0.806 |
Transformer 落后 CNN 14pp——所有"踩坑"章节的问题都集中暴露了。
完整消融过程
把四个改进点叠加起来,从 baseline 到最优消融的完整过程(5-fold CV 准确率 ± std):

每一栏对应表中的一个实验:baseline → +金字塔增强 → +强正则化 → +多窗口长度测试。CNN baseline(0.944)和 LDA baseline(0.910)作为红线/橙线参考。
金字塔数据增强
ERP 的响应主要集中在前 300ms,后半段和 baseline 都是噪声。随机截取不同长度的子段,既增加训练样本,又让模型学到多尺度特征:
1 | buckets = {106:1, 100:2, 95:4, 90:8, 85:16, 80:27} |
配合增强正则化(dropout=0.4, weight_decay=1e-3, early stop 5 epoch):
| 方案 | Accuracy |
|---|---|
| Transformer + 增强 + 正则化 | 0.844 ± 0.025 |
+3.8pp,方差减半,但仍差 CNN 10pp。
GPT vs BERT:因果 vs 双向的消融实验
这个是控制变量最严格的实验——绝大部分参数一致:
| GPT(aug_v3) | BERT | |
|---|---|---|
| 注意力 | 因果掩码 | 双向(无掩码) |
| 分类头 | 最后位置 | [CLS] token |
| 其他参数 | 完全一致 |
1 | # 差异只有两行代码: |
结果:
| 模型 | Accuracy |
|---|---|
| GPT(因果+最后位置) | 0.844 ± 0.025 |
| BERT(双向+CLS) | 0.865 ± 0.035 |
BERT 在完全相同的数据和超参数下,比 GPT 高了 2pp。
说明:离线整段分类场景下,双向注意力就是比因果好——模型能看到"未来"的脑电信号,自然能做出更准确的判断。因果注意力的价值在需要逐时刻预测(不允许泄漏未来)的场景,比如在线解码;离线分析场景下用因果等于人为加约束。
窗口长度陷阱
一个有趣的发现:用较短的测试窗口(85 个时间点,约 -100ms ~ +300ms)比用完整 106 点窗口准确率高 3.4pp:
| 测试窗口长度 | Accuracy |
|---|---|
| 106(完整) | 0.844 |
| 85 | 0.878 |
不是因为 Transformer 喜欢短窗口——而是 ERP 的响应(N100/P200)集中在 100-300ms,前后都是噪声。窗口选错了,不是架构的问题。
但这里有一个 confound:金字塔增强中 L=80-85 占训练数据的 74%,模型对短窗口"练得更多"。这个分布偏差和 SNR 提升纠缠在一起,目前没有完全解耦。
跨窗口长度的完整消融(无需重新训练,v3 checkpoint 直接推理):

三条线对应三种 ensemble 策略:single_random(单次随机切片)、per_slice_avg(按 slice 平均概率)、mean_logits(logits 空间平均再过 softmax)。mean_logits + L=85 达到 0.878。
完整对比表
| 实验 | Acc ± Std | Δ vs CNN |
|---|---|---|
| Transformer baseline(无增强) | 0.806 ± 0.033 | -13.8pp |
| Transformer + 增强 + 正则化 | 0.844 ± 0.025 | -10.0pp |
| -> 换成双向 + CLS | 0.865 ± 0.035 | -7.9pp |
| -> 最优窗口 L=85 | 0.878 ± 0.031 | -6.6pp |
| CNN baseline | 0.944 ± 0.030 | — |
方法论教训
- Loss → 0 不是瓶颈:即使 strong reg 下 loss 也归零,但 acc 不动了——模型容量够,数据/输入域是瓶颈
- 系统消融能救命:增不增强→窗口长短→因果还是双向,每一个变量都推高了一点。不做系统性消融,结论就是错
- CNN 不一定不如 Transformer:在 288 trial 的小数据集上,CNN 依然是最强的——数据量没大到需要 Transformer 的优势
训练细节
- 优化器:AdamW(lr=5e-4, weight_decay=1e-4)
- 损失:CrossEntropyLoss
- baseline:100 epoch
- 金字塔增强后:5 epoch(loss 到 0 就停,防止过拟合)
- batch_size=23(增强后 13340 样本 / 每 fold,保证每 batch 看到不同长度的切片)
- 不用 DataLoader——数据直接放 GPU,手动 shuffle index 切 mini-batch
踩过的坑
- EEG 数据量太小:BCI 数据集通常几十到几百个 trial,Transformer 容易过拟合。数据增强(金字塔截取、高斯噪声、时间平移)是必需的
- RoPE 外推不是免费的:推理时 token 数超过训练时的 1.5 倍以上,准确率会明显下降。不是"支持任意长度"就真的随便用
- d_model 不要太大:EEG 通道数通常 32-64,d_model=64 已经够用。512 是 NLP 的配置,在 EEG 上只会过拟合
- 离线整段分类因果注意力不是最优解:双向注意力比因果高 2pp。因果注意力的价值在需要逐时刻预测(不泄漏未来)的场景,不在离线整段分类
- 窗口长度要结合领域知识:默认从刺激开始取 -200~+424ms 不一定是最优的。ERP 的响应集中在 N100/P200,舍掉前后噪声能直接提 3.4pp。知道脑电的生理特性比调参数更重要
总结
把 Transformer 从 NLP 搬到 EEG,不是换个输入格式就完了。Token 怎么构建、位置怎么编码、注意力要不要因果、分类头取哪个位置——每个设计选择都需要根据 EEG 的特性重新思考。
这个实现不是 SOTA,但它完整地回答了一个问题:如果要从零实现一个 EEG Transformer,需要做哪些设计决策,以及为什么。 加上后续实验验证,每一条判断都有了定量依据——哪些对了(RoPE、Pre-LN、Conv1d token embedding),哪些需要 trade-off(因果 vs 双向),哪些其实是数据的问题而不是模型的问题(窗口选择)。