为什么用 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
2
3
4
5
6
7
8
9
class _TokenEmbedding(nn.Module):
def __init__(self, n_channels, kernel, stride):
super().__init__()
self.conv = nn.Conv1d(n_channels, n_channels,
kernel_size=kernel, stride=stride)

def forward(self, x):
# x: (B, n_ch, n_times) → (B, n_tokens, n_ch)
return self.conv(x).transpose(1, 2)

为什么用 Conv1d 而不是固定分帧:信号处理里"分帧"是按固定窗口切信号、每帧取均值或原始采样点——这是不可学习的。Conv1d 做的事本质相同(kernel=窗口,stride=步长),但权重可学习:kernel 内每个时间点的贡献由训练决定,比分帧平均更有表达力。

kernel/stride 怎么选:如果用户不指定,自动算。目标是让 n_tokens ≈ 128,50% 重叠(kernel = 2 * stride):

1
2
stride = max(1, n_times // target_tokens)
kernel = stride * 2

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
2
3
4
5
6
7
8
9
10
11
class _RotaryPositionalEmbedding(nn.Module):
def __init__(self, d_model, max_seq_len=4096, base=10000.0):
super().__init__()
inv_freq = 1.0 / (base ** (torch.arange(0, d_model, 2).float() / d_model))
self.register_buffer("inv_freq", inv_freq, persistent=False)

def forward(self, x, positions):
freqs = torch.outer(positions.float(), self.inv_freq)
cos = freqs.cos().repeat_interleave(2, dim=-1)
sin = freqs.sin().repeat_interleave(2, dim=-1)
return x * cos + self._rotate_half(x) * sin

核心思想:位置 m 的 Q/K 不加偏置,而是旋转。_rotate_half 把特征维的相邻两个值当作一对,做 2D 旋转。内积 Q·K 自然包含相对位置信息 m-n,不需要显式的位置编码表。

3. 因果注意力:只看过去,不看未来

这是 GPT 风格 Transformer 的标志性设计。标准 BERT 式自注意力每个位置能看所有位置,但 EEG 解码如果用双向注意力,模型会"偷看"未来的脑电信号——这在需要逐时刻推理(每个时间点只能用过去信息预测)的场景下是不允许的。因果掩码保留了这条约束。

因果掩码:上三角为 -inf,softmax 后变成 0:

1
2
3
4
5
class _CausalMask(nn.Module):
def __init__(self, size):
super().__init__()
mask = torch.triu(torch.full((size, size), float("-inf")), diagonal=1)
self.register_buffer("mask", mask, persistent=False)

在注意力分数上加这个掩码,位置 t 只能 attend 到 0…t。

4. Pre-LN + 残差:稳定训练

1
2
3
4
5
class _DecoderBlock(nn.Module):
def forward(self, x):
x = x + self.attn(self.ln1(x)) # Pre-LN: 先归一化再进注意力
x = x + self.ffn(self.ln2(x)) # 同理
return x

Pre-LN(先 LayerNorm 再进子层)比 Post-LN(先子层再 LayerNorm)训练更稳定。这是 GPT-2 以后的标准做法。FFN 扩张比 4×,用 GELU 激活。

5. 分类头:取最后一个位置

GPT 没有 [CLS] token,那取哪个位置做分类?

取最后一个:x[:, -1, :]。因为因果注意力下,最后一个位置积累了前面所有位置的信息——它"看"了整段信号。

但这是唯一的选择吗?并不是。双向注意力场景下,BERT 的做法更自然:在序列开头插入一个可学习的 [CLS] token,让它 attend 到所有位置,分类器读 [CLS] 输出。

1
2
3
4
5
6
7
8
9
10
class _EEGTransformerBert(nn.Module):
def forward(self, x):
x = self.token_embed(x) # (B, n_tokens, n_ch)
x = self.input_proj(x) # (B, n_tokens, d_model)
cls = self.cls_token.expand(B, -1, -1) # (B, 1, d_model)
x = torch.cat([cls, x], dim=1) # (B, n_tokens+1, d_model)
for block in self.blocks: # bidirectional MHA
x = block(x)
cls_out = self.ln_final(x[:, 0, :]) # (B, d_model)
return self.classifier(cls_out)

两种方案后来做了对比实验(相同数据、相同的金字塔增强、相同正则化强度),具体见实验部分。

6. 长度自适应推理

推理时 EEG 长度可能和训练时不一样。因为用了 Conv1d token embedding + RoPE,模型天然支持任意长度输入(只要 n_times >= kernel)。如果推理时 token 数超过训练时,RoPE 的位置会外推,给一个警告:

1
2
if n_tokens > self._train_n_tokens:
warnings.warn("RoPE position extrapolation; accuracy may degrade")

实验里 kernel=1, stride=1_train_n_tokens=106。推理时如果给了更长的时间段(比如 120 个时间点),会产生 120 个 token,RoPE 外推;给了更短的则没有外推问题。

7. 逐通道 z-score 归一化

EEG 通道间的幅度差异很大(额区 vs 枕区),不归一化的话梯度会被大信号通道主导。训练时计算每个通道的均值/标准差,推理时用同样的统计量:

1
2
3
4
5
if self.normalize:
self._mean = X.mean(axis=(0, 2), keepdims=True)
self._std = X.std(axis=(0, 2), keepdims=True)
self._std = np.where(self._std < 1e-8, 1.0, self._std) # 零方差通道保护
X = (X - self._mean) / self._std

均值和标准差随模型一起保存,推理时自动应用。

实验验证:在 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
2
3
4
5
6
7
8
# CNN: 卷积核扫过 (ch, time) 空间
class _EEGCNN(nn.Module):
def __init__(self, n_channels, n_times, n_classes):
self.conv1 = nn.Conv2d(1, 16, kernel_size=(n_channels, 3))
self.conv2 = nn.Conv2d(16, 32, kernel_size=(1, 3))
self.fc = nn.Linear(flatten_size, n_classes)

# LDA: sklearn Pipeline(StandardScaler + PCA(0.95) + LDA)
模型 Accuracy
CNN(baseline) 0.944
LDA 0.910
Transformer(因果+最后位置,无增强) 0.806

Transformer 落后 CNN 14pp——所有"踩坑"章节的问题都集中暴露了。

完整消融过程

把四个改进点叠加起来,从 baseline 到最优消融的完整过程(5-fold CV 准确率 ± std):

Transformer ablation progression

每一栏对应表中的一个实验:baseline → +金字塔增强 → +强正则化 → +多窗口长度测试。CNN baseline(0.944)和 LDA baseline(0.910)作为红线/橙线参考。

金字塔数据增强

ERP 的响应主要集中在前 300ms,后半段和 baseline 都是噪声。随机截取不同长度的子段,既增加训练样本,又让模型学到多尺度特征:

1
2
3
buckets = {106:1, 100:2, 95:4, 90:8, 85:16, 80:27}
# 每个 trial → 58 个不同长度的切片
# L=80 的窗口占总训练数据的 46.6%

配合增强正则化(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
2
3
4
5
6
# 差异只有两行代码:
# GPT: scores = scores + causal_mask(T) # 只看过去
# BERT: pass # 没有掩码,看全部

# GPT: return classifier(x[:, -1, :]) # 取最后一个 token
# BERT: return classifier(cls_out) # 取 [CLS] 的输出

结果:

模型 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 直接推理):

Multi-length test evaluation

三条线对应三种 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

方法论教训

  1. Loss → 0 不是瓶颈:即使 strong reg 下 loss 也归零,但 acc 不动了——模型容量够,数据/输入域是瓶颈
  2. 系统消融能救命:增不增强→窗口长短→因果还是双向,每一个变量都推高了一点。不做系统性消融,结论就是错
  3. 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

踩过的坑

  1. EEG 数据量太小:BCI 数据集通常几十到几百个 trial,Transformer 容易过拟合。数据增强(金字塔截取、高斯噪声、时间平移)是必需的
  2. RoPE 外推不是免费的:推理时 token 数超过训练时的 1.5 倍以上,准确率会明显下降。不是"支持任意长度"就真的随便用
  3. d_model 不要太大:EEG 通道数通常 32-64,d_model=64 已经够用。512 是 NLP 的配置,在 EEG 上只会过拟合
  4. 离线整段分类因果注意力不是最优解:双向注意力比因果高 2pp。因果注意力的价值在需要逐时刻预测(不泄漏未来)的场景,不在离线整段分类
  5. 窗口长度要结合领域知识:默认从刺激开始取 -200~+424ms 不一定是最优的。ERP 的响应集中在 N100/P200,舍掉前后噪声能直接提 3.4pp。知道脑电的生理特性比调参数更重要

总结

把 Transformer 从 NLP 搬到 EEG,不是换个输入格式就完了。Token 怎么构建、位置怎么编码、注意力要不要因果、分类头取哪个位置——每个设计选择都需要根据 EEG 的特性重新思考。

这个实现不是 SOTA,但它完整地回答了一个问题:如果要从零实现一个 EEG Transformer,需要做哪些设计决策,以及为什么。 加上后续实验验证,每一条判断都有了定量依据——哪些对了(RoPE、Pre-LN、Conv1d token embedding),哪些需要 trade-off(因果 vs 双向),哪些其实是数据的问题而不是模型的问题(窗口选择)。