简介:一份来自浙江大学《学报(工学版)》的学术论文PDF,面向脑机接口、深度学习与生物信号处理方向的研究者、研究生及工程师。内容围绕卷积神经网络(CNN)对脑电信号(EEG)进行上肢运动意图识别,针对传统手工特征提取易造成信息损失的问题,采用CNN自动学习特征,并与BP神经网络结合的特征提取方法进行对比实验。论文完整呈现了左右手2分类、单手3分类实验设计、数据处理流程与精度结果,显示CNN识别精度分别提升约4%和8%,能增强动作预测可靠性,对脑机交互控制及中枢神经信号与手部动作关系的理解有直接帮助。资源包含1个PDF,大小4.36MB,正文含摘要、图表、公式与参考文献,便于直接阅读、标记和论文改写,适合作为算法复现、课程设计或科研引用的参考资料。目前已有206人学习下载。
1. 先说清楚:卷积神经网络在上肢运动意图识别里解决什么问题
这个标题其实是一个脑机接口任务。抛开.pdf后缀,它要做的是把头皮脑电信号映射成“想动左手还是右手”“手臂想抬还是想伸”这样的离散意图。脑电是非平稳的,单个电极上的电压波形很难直接用阈值判断,而运动意图主要表现为 mu 节律(8-13 Hz)和 beta 节律(13-30 Hz)在运动皮层区域出现事件相关去同步和事件相关同步。传统做法是人工提取频带功率、共同空间模式特征再进 SVM,但这些特征对新被试、新电极摆放都比较敏感。卷积神经网络的优势在于把“提取特征”和“分类”放进同一个网络里,卷积核在时间维和通道维滑动,自动找哪些时刻、哪些脑区组合最能区分意图。这篇文章我会按自己做的一套流程讲清楚:脑电数据怎么整理成张量、CNN 结构怎么选、训练交叉验证有哪些坑,最后说跨被试和在线识别的几个进阶技巧。适合刚接触 BCI 的研究生,也适合想把运动意图接出来做康复控制的工程师。
2. 脑电信号上肢运动意图识别的前置工作:从原始波形到 CNN 输入张量
2.1 为什么 CNN 对脑电有效:多通道时空特征的局部性
脑电数据本质上是一个多通道时间序列,形状是(时间点,通道数)。但直接把它当成图像丢进 2D CNN 不合适,因为电极之间的二维排列并不完全等同于图像的空间结构。常见做法是把每个采样点当成一维通道数,使用一维卷积处理时间维度,或者把通道维度视为卷积核要覆盖的“空间维”,用二维卷积同时卷时间和空间。
这里要理解一个关键:CNN 能奏效不是因为脑电有像素级别的拓扑结构,而是因为卷积具有局部连接和权值共享。运动皮层在 C3、C4、C1、C2 这些电极位置上的信号存在相关性,且这种相关性在一定时间窗内稳定;卷积核能学习到“C3 和 C4 的功率差”这样的模式,而不需要为每个电极单独设置特征提取器。另外,意图发生的时刻会相对刺激标记有几百毫秒延迟,卷积的平移不变性让网络不依赖信号刚好对齐在某个固定位置,这比全连接网络鲁棒得多。
所以,CNN 的结构图不需要画得太复杂,核心就两条线:一条线在时间维度做滑动窗口卷积,另一条线把相邻通道合并成空间模式。理解这一点后,参数选择会更有方向。
2.2 数据准备:公开数据集和自采数据的共同要求
无论是 BCI Competition IV 2a 这种四类运动想象数据集,还是自己采集的左右手运动意图数据,喂给 CNN 之前都要把“原始 EDF + 事件标记”处理成(训练样本数, 通道数, 时间点数)的三维张量。下面是一段常见的 MNE 预处理代码,我一般会先跑通它再谈模型:
import mne import numpy as np def build_tensor(edf_path, event_ids, tmin=0.5, tmax=3.0, band=(8, 30)): raw = mne.io.read_raw_edf(edf_path, preload=True) # 带通滤波到 mu/beta 节律,兼顾运动想象的主要频段 raw.filter(*band, fir_design='firwin') # 使用 standard_1020 电极定位,方便后续通道重排和伪迹剔除 montage = mne.channels.make_standard_montage('standard_1020') raw.set_montage(montage, on_missing='warn') events, event_id = mne.events_from_annotations(raw, event_id=event_ids) epochs = mne.Epochs( raw, events, tmin=tmin, tmax=tmax, baseline=(tmin, 0), # 用刺激前数据做基线校正 picks='eeg', preload=True ) X = epochs.get_data() y = epochs.events[:, -1] return X, y这段代码有四个需要重点认识的参数。tmin=0.5表示从刺激出现后 0.5 秒开始取数据,之所以不直接取 0,是因为运动准备电位和指令提示之间存在传导延迟,截取后期更稳定;tmax=3.0是脑电分段长度,窗口越长时间信息越多,但样本越短对在线应用越不友好;band=(8, 30)落在 mu/beta 节律范围,低于 8 Hz 的慢漂移和高于 30 Hz 的肌电伪迹会被滤掉;baseline=(tmin, 0)将刺激前那段时间归一到基线,减少通道间直流偏置差异。执行成功后X.shape一般是(n_epochs, n_channels, n_timepoints),这个形状可以直接交给 PyTorch 的Conv2d,只要在送入网络前补上通道维。
2.3 预处理流程里容易影响 CNN 效果的细节
滤波只是第一步。实际项目中我会按下面的顺序处理,每一步都有它的道理:
- 先把坏导联和坏段剔除。脑电很容易被眨眼、咬牙和头部移动污染,一个幅值超过 ±150 μV 的样本会对 BatchNorm 产生明显扰动。常见做法是用 MNE 的
auto_mark_bad_channels或人工目检,至少要把明显断路的通道标记出来。 - 再做独立成分分析(ICA)去掉眨眼伪迹。ICA 对脑电的分解时间和数据量都要求高,小样本下不必逐样本手工挑选分量,可以通过
find_bads_eog自动选择 EOG 分量。 - 最后重采样。如果原始采样率是 1000 Hz,建议降到 128 Hz 或 250 Hz。CNN 的显存占用随时间点数线性增长,而运动意图的判别信息主要在 8-30 Hz,1000 Hz 只会让网络去拟合噪音。
把这三步放在模型之前执行,比在训练阶段加数据增强更稳定。特别是在自采数据上,EEG 的个体差异比公开数据集更大,预处理一致性决定了跨被试验证上限。
| 参数 | 常用值 | 影响 |
|---|---|---|
| 带通滤波 | 8-30 Hz | 过低会掺入漂移,过高会带入肌电伪迹 |
| 分段起点 | 0.5-1.0 s | 太早会包含准备电位,太晚丢掉决策信息 |
| 窗口长度 | 2-4 s | 长窗信息多但在线延迟高 |
| 采样率 | 128-250 Hz | 过高增加计算量,不提高分类精度 |
| 基线校正 | 刺激前0.2-0.5s | 消除直流漂移 |
2.4 构造输入张量:形状、通道顺序和数据增强
交给 CNN 前的张量是四维的:(batch_size, 1, n_channels, n_times),这里的 1 表示网络输入是一个“单通道图”,图的高度是电极数,宽度是时间点。PyTorch 的Conv2d要求输入为(N, C, H, W),所以要把epochs.get_data()增加一个维度,下方这行代码能完成转换:
X_tensor = X[:, np.newaxis, :, :] # (n_epochs, 1, n_channels, n_times) X_tensor = (X_tensor - X_tensor.mean(axis=-1, keepdims=True)) / (X_tensor.std(axis=-1, keepdims=True) + 1e-8)很多初学者会在这一步只做全样本标准化,忽略每个时间窗内部漂移。我第一行代码先把通道维保留,对每个样本的每个通道独立做零均值归一化,目的是消除不同电极之间的接触阻抗差异。后面一行里的+1e-8是为了避免平坦通道标准差为零导致除零。常见做法是在每个 batch 内用同样的方式处理吗?不是,强烈建议针对每个 epoch 样本归一化,因为脑电是非平稳的,整段数据一起标准会让靠近训练集或测试集边缘的样本产生偏移。
数据增强方面,小幅时间偏移和通道 dropout 在样本量少时管用。例如在时间轴上随机平移 10-20 个采样点,相当于扩充了 ERD 发生的相对时间;随机丢弃 2-3 个通道并补零,可以让网络不过度依赖某个电极。但幅度要小,脑电信噪比本来就低,增强过猛反而把意图模式洗掉。
3. 构建基于卷积神经网络的脑电特征提取结构:CNN 结构设计与参数选择
3.1 从最简单的时域 CNN 开始
我见过的运动意图识别模型,基本都能收敛的版本不需要花哨结构。先写一个两层卷积的基线:第一层只卷时间,第二层把通道维和时间窗都卷掉,再接全连接分类。该结构的 PyTorch 实现如下,代码可直接复制到项目中跑通:
import torch.nn as nn class SimpleEEGCNN(nn.Module): def __init__(self, n_chans, n_classes): super().__init__() self.features = nn.Sequential( nn.Conv2d(1, 16, (1, 64), padding=(0, 32)), nn.BatchNorm2d(16, momentum=0.01), nn.ELU(), nn.AvgPool2d((1, 8)), nn.Conv2d(16, 32, (n_chans, 16), padding=(0, 8)), nn.BatchNorm2d(32, momentum=0.01), nn.ELU(), nn.AvgPool2d((1, 4)), nn.AdaptiveAvgPool2d((1, 1)), nn.Flatten(), nn.Dropout(0.5), nn.Linear(32, n_classes) ) def forward(self, x): return self.features(x)输入x形状是(batch, 1, n_chans, n_times)。第一个卷积核(1, 64)只覆盖时间轴,等价于一个 64 点的 FIR 滤波器;padding=(0, 32)让输出时间长度不变。BatchNorm 和 ELU 组合比 ReLU 更稳,因为脑电有负向偏置。池化AvgPool2d((1, 8))只在时间维做 8 倍降采样,避免压缩通道信息。第二个卷积核是(n_chans, 16),它的第一维刚好等于通道数,因此卷积结果在通道维收缩为 1,相当于把全部电极的空间组合压缩成一个空间模式;第二维 16 继续捕捉短时间窗内的节奏变化。再池化一次后用全局平均池化,把所有特征汇成一个 32 维向量,交给全连接层。
注意:如果n_chans是 64,第二个卷积的核高为 64,这会带来不小的计算量,也让模型容量偏大。小样本场景下我一般会在这个卷积前插入一个Dropout2d(0.2),防止模型把空间滤波学得过度刚硬。
3.2 小样本更友好的 EEGNet 结构
上面那个基线模型参数量不算小,如果每个被试只有一两百个样本,很容易过拟合。此时可以换成 EEGNet 这类深度可分离卷积网络:先用一个普通时间卷积拆分频段,再用逐通道的空间卷积学习每个电极的空间权重,然后用可分离卷积进一步提取时间模式。这样做的好处是参数量大幅下降,且空间卷积只在单个通道内进行,不会因为少量样本把通道间组合学坏。
| 层 | 核大小 | 输出形状(假设64通道,128Hz,3s数据) |
|---|---|---|
| 输入 | - | (1, 64, 384) |
| 时间卷积 | (1, 64) | (8, 64, 384) |
| 空间深度卷积 | (64, 1) | (16, 1, 384) |
| 平均池化 | (1, 8) | (16, 1, 48) |
| 可分离卷积 | (1, 16) | (16, 1, 48) |
| 平均池化 | (1, 8) | (16, 1, 6) |
| 全连接 | - | 2 |
表中的输出形状是理想情况,实际 padding 不同会略有差异。实现上,空间深度卷积在 PyTorch 中可以用Conv2d的groups=16来写,权重数量可以控制在数千级别。对上肢运动意图识别来说,我通常只取 EEGNet 的 F1=8、D=2、F2=16 这一档,dropout 设在 0.25-0.5 之间。
3.3 卷积核大小、池化和 dropout 的选取规则
表格里的数字不是拍脑袋定的。卷积核长度直接影响模型能看到的“时间窗口”。如果采样率是 128 Hz,64 个点相当于 0.5 秒,这刚好覆盖一个 mu 节律的 4-5 个周期;太短的核学不到 ERD 的持续变化,太长会把多个节律混在一起。pool size一样有考究:时间维池化 8 倍,就是把 64 点压缩成 8 点,保留的是节律包络而不是原始波形。池化后再做一次小卷积,相当于让网络在降采样后的特征上再次提取局部时间模式。
Dropout 的位置比数值更关键。卷积层后使用普通Dropout会随机丢弃通道,可能破坏空间结构;我一般在前几层用Dropout2d,在全连接层前用普通Dropout。另外,BatchNorm 的动量默认是 0.1,对脑电这种小批量数据偏小,容易出现训练集和验证集的 BatchNorm 统计不一致。可以把BatchNorm2d的momentum调到 0.05 或 0.01,验证集指标会稳定不少。
3.4 什么时候考虑 2D 拓扑卷积和 3D 卷积神经网络
如果只做事件相关去同步,一维时间卷积已经够用。但有些意图和电极的空间分布关系很强,比如左手运动时 C4 区域更活跃,右手运动时 C3 更活跃。这时可以把电极坐标投影到 2D 平面,用插值生成一张“类脑地形图”,再用 2D CNN 学习空间拓扑。更进一步的 3D 卷积神经网络会把(通道, 时间)也看作三维数据,用立体卷积核同时建模空间和时间。实际应用中,2D/3D 模型的参数容量大,公开脑电数据集通常只有几百个样本,不加强正则化很难超过浅层 CNN。我的建议是先用一维时间卷积和 EEGNet 拿到基线,再用 2D 拓扑模型做对比;除非你的自采数据有几个小时的数量级,否则不要直接上 3D 卷积神经网络。
4. 训练与评估:让 CNN 学会区分不同上肢运动意图
4.1 数据划分:按被试切分是底线
运动意图识别最常犯的错误是直接把所有 epoch 随机打乱后划分训练集和测试集。同一个被试、连续采集的样本有强自相关性,随机划分会把很多几乎重复的片段放进测试集,导致准确率虚高。正确的做法是把数据按被试划分,让测试集里的人从未出现在训练集中。利用下面的循环可以完成留一被试交叉验证:
subjects = [1, 2, 3, 4, 5, 6, 7, 8, 9] test_results = [] for test_sub in subjects: train_sub = [s for s in subjects if s != test_sub] X_train = np.concatenate([X_by_sub[s] for s in train_sub], axis=0) y_train = np.concatenate([y_by_sub[s] for s in train_sub], axis=0) X_test = X_by_sub[test_sub] y_test = y_by_sub[test_sub] acc = train_and_evaluate(X_train, y_train, X_test, y_test) test_results.append(acc) print(f"平均准确率: {np.mean(test_results):.2f}")这段代码里train_and_evaluate是你自己的训练函数,关键点是每次循环只保留一个被试做测试。留一被试交叉验证的结果会比随机划分低 5-15 个百分点,这才是新被试上的真实水平。如果你的目标是做跨被试设备,必须按这个方式汇报。
注意:如果样本来自同一被试的连续时段,随机划分训练/验证集也会导致指标虚高。应以被试为最小划分单位。
4.2 损失函数与评价指标
分类任务默认使用交叉熵损失。如果“左手”“右手”“休息”三类样本数不均衡,可以在CrossEntropyLoss里传入权重。比如右手样本只有其他类一半,就把右手的权重设为 2:
loss_fn = nn.CrossEntropyLoss(weight=torch.tensor([1.0, 2.0, 1.0]))评估指标不能只看准确率。BCI 竞赛里常用 Cohen Kappa 系数,因为它排除了类别不平衡带来的虚高。使用 scikit-learn 可以一行算出来:
from sklearn.metrics import cohen_kappa_score kappa = cohen_kappa_score(y_true, y_pred)另外,要画出混淆矩阵。运动意图识别最典型的问题是“左手误判成右手”还是“意图误判成休息”,这两个错误原因完全不同:前者说明模型没有学到 C3/C4 的侧别特征,后者说明模型没捕捉到 ERD/ERS 的幅度变化。只看准确率会掩盖这两类问题。
4.3 训练配置的默认参数与调整策略
| 超参数 | 默认值 | 调整方向 |
|---|---|---|
| 优化器 | Adam | 换 AdamW 并加 weight_decay |
| 学习率 | 0.001 | 过拟合时降到 0.0005 |
| batch size | 32 | 样本太少时降到 16 |
| 最大 epoch | 100 | 看验证集早停 |
| 早停 patience | 20 | 防止小数据集过拟合 |
| Dropout | 0.25-0.5 | 泛化差时增大 |
| 权重衰减 | 1e-4 | 用于缓解过拟合 |
训练时我会把学习率衰减和早停结合起来:
optimizer = torch.optim.AdamW(model.parameters(), lr=0.001, weight_decay=1e-4) scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau( optimizer, mode='max', patience=10, factor=0.5, verbose=True ) best_acc = 0 for epoch in range(200): train_loss = train_one_epoch(model, train_loader, optimizer, loss_fn) val_acc = validate(model, val_loader) scheduler.step(val_acc) if val_acc > best_acc: best_acc = val_acc torch.save(model.state_dict(), 'best_model.pt')这里ReduceLROnPlateau的mode='max'表示监控验证集准确率,patience=10表示连续 10 个 epoch 不提升就降一半学习率;torch.save保存的是验证集最优的权重而不是最后一个 epoch,能有效避免早停前的抖动。
4.4 训练失败的常见信号和排查顺序
第一,loss 完全不动。先检查输入张量,脑电幅度通常只有几十微伏,如果直接喂进网络,梯度可能被数据尺度干扰。先对每个样本按通道做标准化,再用 1e-3 的学习率开始。第二,训练集准确率接近 100% 但验证集很差,说明模型容量太大,按表格减小卷积核数、增大 dropout。第三,验证集始终输出同一个类别,除了样本不均衡外,也要检查事件标记是否真的对齐,很多自采数据在刺激触发时刻和实际运动开始有 100-200 ms 抖动,先手动画几条 epoch 的平均波形确认。第四,跨被试验证比基线低很多,优先检查滤波和基线校正是否逐个样本做了,而不是网络结构。
每个问题都要先排除数据再调模型。脑电信号质量对结果的影响远大于模型选型,换网络不会解决坏电极带来的问题。
5. 进阶技巧:跨被试泛化、显著图与在线滑动窗口
5.1 用逐样本标准化提升跨被试稳定性
跨被试场景里,不同被试的脑电振幅差异非常大。最简单有效的技巧是在送入网络前对每个样本的每个通道做零均值单位方差标准化,而不是使用全局统计量。代码在前面已经给出,这里强调两点:标准化必须在数据增强之后、进入模型之前;如果在在线推理时,只保存训练时的均值方差会导致新被试整体幅值偏移被忽略。另一种常见做法是用欧几里得对齐调整所有样本的协方差矩阵,但它的计算开销较大,适合离线研究。
5.2 用显著图找到模型依赖的通道和时间窗
运动意图识别不仅要分类,还要回答“为什么判断成左手”。利用模型输出的梯度,可以对输入生成一个时间-通道显著图:
def saliency(model, x, target_class): x = x.clone().requires_grad_(True) logits = model(x) model.zero_grad() logits[0, target_class].backward() grad = x.grad[0, 0].abs().mean(dim=0).cpu().numpy() return grad返回的grad形状是(n_channels, n_times),数值大的位置表示该通道和该时刻对分类决策影响最大。我一般会把 C3、C4、Cz 通道的显著图单独画出来,观察模型是否真的聚焦在运动准备和时间相关去同步窗口。如果显著图集中在眼睛附近的电极,说明预处理中的 ICA 没有把眨眼伪迹去干净。
5.3 在线识别:滑动窗口和概率平滑
把 CNN 用到实时上肢控制时,不能等完整 3 秒窗口结束才给结果。常见做法是使用 1 秒滑动窗口,步长 100 ms,每 100 ms 输出一次预测概率,再用指数平均平滑。参数方面,窗口减短会降低单次准确率,但平滑后反而更稳。指数平滑系数 α 取 0.2-0.3,表示新预测权重较低,避免单帧抖动。另外,连续 5 帧概率都超过 0.6 才触发命令,否则不动作,这是把意图落到外设控制时最实用的防误触手段。把这几行逻辑加进推理循环,在线识别才真正靠谱。
本文还有配套的精品资源,点击获取