如果你也玩过脑电运动成像(Motor Imagery, MI)分类,一定体会过那种"调了无数个模型,准确率就是卡在60%上下"的感觉。我第一次跑左右手运动想象分类就是这样,模型换了好几版,效果纹丝不动。后来把问题拆开看,最可疑的不是网络结构,而是输入:EEG是非平稳、低信噪比的生理信号,运动想象在头皮上留下的痕迹又浅又散,单纯把原始波形或者功率谱丢给CNN,模型根本不知道"在哪段时间、哪个频带"去抓特征。
于是我把小波变换和卷积神经网络放在一条流水线里:先用连续小波变换把每一段脑电变成时频图,再把时频图堆叠成多通道图像喂给CNN。这篇文章就把这条链路完整讲一遍——原理是什么、数据怎么处理、网络怎么搭、我踩过哪些坑。内容比较适合正在做运动想象分类实验的同学参考,无论你是从传统特征分类转向深度学习,还是想给已有网络换一种输入形态,都能看到一条可以照抄的路径。
1. 运动想象到底在脑电里藏了什么特征,传统方法为何头疼
1.1 左右手想象时,mu/beta节律会发生什么变化
脑电运动成像分类,业内常说运动想象分类,说到底是在区分"想象左手动"和"想象右手动"这两种状态下,大脑皮层电活动模式的差异。人执行或想象肢体运动时,感觉运动皮层区域的mu节律(8~13Hz)和beta节律(13~30Hz)会出现能量下降,这个现象叫事件相关去同步(ERD);而在想象结束或运动静止时,能量又可能回升,叫事件相关同步(ERS)。
关键点在于,这种能量变化有空间特异性。想象右手动,主要引起左侧运动皮层对应的电极位置ERD;想象左手动,右侧对应位置更明显。而且这个过程不是固定的,它在想象开始前悄悄出现,想象持续过程中变化,想象结束后又恢复。也就是说,有用的信息同时分布在频率、时间和空间三个维度上,单独看任何一方面都容易漏掉。
我刚接触这个方向时犯过一个典型错误:只盯着功率谱特征看,发现左右手想象的平均功率差没那么大,就怀疑数据有问题。其实不是,功率谱是把整段信号的时间信息平均掉了,ERD/ERS的动态变化恰恰是分类的关键。这让我意识到,必须引入既保留频率信息、又保留时间变化过程的处理方式。
1.2 传统"滤波+CSP+分类器"流水线的问题
传统做法一般是这样一个套路:先对EEG做带通滤波,选8~30Hz这个与运动想象最相关的频段;然后把数据按试次切段;再用共空间模式(CSP)计算空间滤波器,让两类信号在投影后的方差尽量分开;最后用LDA、SVM这类分类器做判别。
这套流程有效果,但踩过的人都知道它有明显的局促感。CSP这类算法很依赖通道选择和频带选择,而不同被试运动想象的特征频带有差别,有人8~12Hz明显,有人可能在20Hz左右更强。于是就有了各种扩展方案,比如多频带CSP、滤波器组CSP,本质上是在手动搜索每个人最合适的频率窗口。
另一个麻烦是,CSP是对整段时间计算统计量,它给出的特征本质上仍然是一次性压缩。运动想象过程是动态的,同样的能量变化,是出现在想象前段还是后段,对模型可能有不同含义,但CSP不区分。传统流水线不是不好,而是它把"人工设计特征"这件事压在开发者身上,每换一批数据,特征工程几乎要重来一遍。
1.3 小波+CNN这条路线到底补上了哪块短板
小波变换和卷积神经网络搭配,恰好补上了传统方法的两个痛点。小波变换替我们把"时频联合分布"这个最耗精力的特征提取工作做了,它把一维EEG波形变换成二维的时频图,频率和时间信息都保留下来;CNN那边,则用堆叠的卷积层自动学习时频图上的局部模式,不再需要人手动指定具体哪个频带最重要。
这件事的本质是,把传统"人工设计特征+浅层分类器"替换成"信号级时频表示+深度特征学习"。CNN不是在学习原始波形,而是在学习小波变换后的一张图。图上某个位置代表某个时间点的某个频率成分有多强,这种表示对卷积核非常友好,因为脑电的节律特征本身就是局部频率、局部时间的变化。
当然也不是没有代价。小波变换参数的设置、时频图的尺寸、通道怎么组织,这些都会直接影响最终效果。我最初把时频图直接按单通道丢进CNN,效果稀烂,后来才发现问题出在多通道组织方式上。这些细节后面都会展开。
2. 小波变换凭什么比固定窗傅里叶更适合脑电
2.1 傅里叶和短时傅里叶的"窗口困境"
先看傅里叶变换。它能告诉你一段信号里包含哪些频率成分,但给出的是一整个时间段内的平均结果。它对平稳信号很好用,但脑电从来不是平稳信号,被试可能在某个瞬间眨眼,可能在想象过程中突然分心,这些瞬态变化虽然占比不大,却可能蕴含着分类信息。
为了找回时间信息,短时傅里叶变换(STFT)出现了:把信号切成很多短窗口,对每个窗口做傅里叶变换,再把结果按时间拼起来,得到一张二维的时频谱图。它的设计思路很简单,但有一个绕不开的物理限制——窗口宽度决定频率分辨率和时间分辨率,两者相互制约。窗太短,时间定位准,但频率分辨率差;窗太长,频率分得清,却看不出频率随时间的变化。
脑电里的mu/beta节律变化往往在几百毫秒尺度发生,想同时分辨出10Hz和11Hz的差异、又想定位到某个想象阶段,STFT的参数就会很尴尬,无论选什么窗长,总有一头要牺牲。这就是固定窗口的局限性所在。
2.2 连续小波变换如何同时拿到时间和频率
连续小波变换(CWT)的思路跟STFT完全不同。它不执着于一个固定尺寸的窗口,而是用一个可伸缩、可平移的母小波函数ψ(t)去和信号做内积。伸缩靠尺度参数a控制,平移靠参数b控制,得到的结果是一个关于a和b的二维函数。
大尺度对应的母小波被拉得很宽,相当于用低频长相位的模板去匹配信号,频率分辨率高、时间分辨率低;小尺度对应的母小波被压得很窄,相当于用高频短脉冲去匹配信号,时间定位准、频率分辨率低。脑电不需要我们手动选窗口宽度,小波变换会根据频率自动调节,高频处时间窗窄,低频处时间窗宽,正好符合神经信号的特点。
我把这个思路类比成用不同粗细的筛子去过筛:高频成分是小颗粒,得用细筛来抓,时间范围自然就短;低频成分是大颗粒,用粗筛子抓得更准。小波变换的本质,就是用一组尺度不同的筛子同时工作,让每个频段都能得到和自己匹配的分辨率。
2.3 小波基怎么选:morlet、symlet、其他
小波基的选择没有绝对正确答案,但我根据自己的经验,先给一个直接可用的配置:做脑电时频图,优先尝试morlet小波;做离散小波多级分解,优先尝试symlet系列。
morlet小波是复数小波,形态上很像局部化的正弦波,和脑电的节律振荡特征天然匹配。用morlet做CWT时,横轴是时间,纵轴是频率,画出来的时频图更像"能量在时间和频率上的分布",这种图交给CNN最容易学。symlet系列属于正交离散小波,计算量小,速度快,适合做多分辨率分解,但缺点在于平移不变性不好,直接拿离散小波系数去喂CNN,CNN对信号起点位置的敏感度可能会让训练变得更难。
我实际做过对比,同样一套数据,morlet生成的时频图效果明显优于symlet作为输入的版本。要注意的是,小波函数本身还有参数可调,比如morlet的中心频率,选不同的参数等效于改变频率分辨率的侧重。一般默认参数就能用,但如果你想针对某个被试的mu节律特别明显的特征去做优化,可以手动调整小波的带宽参数。
2.4 为什么我不直接用离散小波变换替代CNN输入
离散小波变换(DWT)看起来也很美好,多级分解之后能得到近似分量和细节分量,每一级对应不同的频率范围,很多人觉得把多个子带信号叠加在一起给CNN就行。我自己试过,效果并没有想象中好,原因有两个。
第一,DWT的分解结果对信号平移极度敏感。运动想象开始的时间有抖动,试次之间的波形也有差异,DWT会把这种细微起点差异放大,使模型需要更高的平移不变性。CWT配合时频图像,天然允许"同样的特征出现在稍微不同的时间位置",CNN的卷积池化结构正好能吸收这种变化。
第二,DWT输出的是多组一维时间序列,网络结构容易滑向"多分支+拼接"的设计,参数量复杂度和调参难度都上去了。CWT直接把特征编码成二维图像,可以直接沿用所有成熟的图像分类网络结构和套路,迁移成本低,稳定性和可复现性也更好。所以我最终主线选择了CWT。
3. 从原始脑电到多通道时频图:完整的数据流水线
3.1 通道选择、带通滤波和分段
讲了一堆原理,现在进入真正工程化的部分。我这次实验使用一批自采的运动想象脑电,三类标签:左手想象、右手想象、空闲状态。正式处理之前,第一步是通道选择,没有把所有64个通道都塞进模型,那样计算量太大,通道间冗余也容易导致过拟合。
我保留了感觉运动皮层对应区域的通道,重点包括C3、C4、Cz这几类。左右手运动想象的信息主要集中在这里,C3靠近左侧运动皮层,C4靠近右侧运动皮层,Cz作为补充。通道数量不需要太多,3~5个有效通道配合时频图,效果往往就够用;如果后面模型过拟合,再继续往下砍通道。
滤波方面,我这里先做0.5~40Hz的带通滤波,再根据当地工频情况加50Hz陷波。滤波要小心边界效应,脑电信号在头尾会有振铃现象,所以我习惯在滤波前对信号做一段边缘延拓,比如用反射方式扩50个点左右,滤波完成后再裁掉,能明显减少边界污染。
分段以运动想象提示出现时刻为0点,取0.5~4.5秒作为分析窗。为什么不要从0秒立刻开始?因为有指令呈现后大脑还需要反应和启动,前500毫秒里混着视觉诱发电位和准备电位,对运动想象分类来说属于干扰。想象结束之后也不全留,后续的ERS恢复阶段特征不稳定,容易造成试次间差异变大。
3.2 坏段剔除与基线校正
脑电采集过程中,被试难免会有眨眼、吞咽、头部微小动作。这些伪迹的幅值通常比正常脑电大得多,如果不处理,CNN很容易学到"怎么识别伪迹",而不是"怎么识别运动想象"。
我的坏段剔除策略比较简单但有效:先计算每段数据的幅值范围,凡是超过±100μV的试次直接剔除;再计算移动方差,如果某段数据在任意1秒窗口内方差异常跳变,也标记为可疑。这套规则会误杀一部分有效数据,但宁缺毋滥,因为少数强伪迹试次对深度模型的影响远比普通异常值大。
基线校正也不能省。我以想象提示前1秒作为基线窗,计算出该窗口内每个通道的平均幅值,然后在整段数据上减去这个均值。这一步帮助去掉直流漂移和皮肤电位等缓慢变化。如果你的数据里漂移严重,还可以再做一次去趋势处理,但一般基线校正加带通滤波就足够。
处理完成后,每类样本量大约在几百个试次,这个规模对于深度学习来说很小,所以后面的数据划分和数据增强策略会变得非常关键。
3.3 用pywt生成时频图的代码与尺度设置
时频图生成我用的是pywt库,核心代码并不复杂,难点在尺度参数的理解。下面是一个可以直接套用的示例,输入是单个通道的一段EEG数据,采样率250Hz。
import numpy as np import pywt def cwt_to_time_freq(eeg_array, fs=250.0, freq_min=1.0, freq_max=40.0): # 先把尺度范围换算好 scales = np.arange(1, 128, 0.5) sampling_period = 1.0 / fs coefficients, freqs = pywt.cwt( eeg_array, scales=scales, wavelet='morl', sampling_period=sampling_period, ) # 只保留 1~40Hz 范围内的系数,减少无效低频和高频成分 mask = (freqs >= freq_min) & (freqs <= freq_max) sub_coefficients = coefficients[mask] sub_freqs = freqs[mask] return sub_coefficients, sub_freqs这里最关键的是理解scales和实际频率的关系。pywt的cwt函数在传入sampling_period后返回的freqs数组已经是换算好的真实频率,不需要再手工乘以某个系数。但如果你使用的是老版本写法,或者自己写CWT循环,一定要记得用pywt.scale2frequency(wavelet, scale) / sampling_period去换算。很多人的时频图频率轴对不上,就是在这个换算上栽了跟头。
尺度范围的选取,我建议不要直接设成1~128就完事。频率分辨率需求决定了尺度的密集程度。如果只关心8~30Hz,可以把尺度范围缩小,让需要关注的频段内有更细的尺度步长。图的分辨率并不是越高越好,太高了数据维度膨胀,训练变慢,还容易过拟合。
3.4 输入张量的组织方式:把时频图"拼"成多通道图像
生成每一段数据的时频图之后,下一个问题是:多个通道怎么组织成CNN的输入。我一开始把每个通道的时频图单独当成一个样本去训练,也就是左手、右手、空闲各几百张图,结果损失一直降不下去。后来想明白了,左右手分类的关键恰恰是多个通道之间的对比关系。
正确的组织方式是:选定N个通道,每个通道生成一张时频图,然后把这N张图沿通道维度堆叠起来,形成一个尺寸为(频率点数, 时间点数, 通道数)的多通道图像。这里通道数对应图像卷积意义上的channel,类似于RGB图像的三通道。模型就能在同一组卷积核里同时看到C3和C4的时频信息,自动学习"C3能量高而C4能量低"这类空间对比特征。
时频图本身的值范围跨度很大,如果不做归一化,CNN的训练会非常不稳定。我采用的方法是每张时频图先做绝对值,然后对整个训练集计算最大值,把所有图统一线性缩放到[0,1]区间。要注意的是,归一化参数只能从训练集统计,验证集和测试集不能参与,否则会造成信息泄漏,后面验证结果虚高自己还发现不了。
4. CNN结构设计:轻量网络与实验对照结果
4.1 卷积核在时频图上扫的是什么
在时频图里,横轴是时间,纵轴是频率,某个像素的明暗代表该时刻该频率的能量强度。卷积核在图上滑动时,本质上是在寻找"局部时间窗口内、局部频率带上的能量变化模式"。
比如一个3×3的卷积核,它看到的可能是一个很小的时间片段里,相邻几个频率成分同时变亮或者同时变暗,这类局部模式正好对应mu/beta节律的ERD事件。再经过池化层,网络的感受野逐渐扩大,就能看到更长时间跨度的节律变化,比如"想象开始后第1到3秒beta能量持续下降"这种整体模式。
这也解释了为什么时频图的尺寸不能乱设。如果时间分辨率太低,比如每个像素代表500毫秒,卷积核就很难定位ERD变化的精确起点;如果频率分辨率太低,频带边缘信息混在一起,mu和beta节律就区分不开。CWT参数和网络结构不是两件独立的事,它们共同决定了模型能学到什么结构。
4.2 一个够用且不容易过拟合的CNN基准
基于上面的理解,我用的网络结构并不复杂,核心思路是轻量化和正则化两手抓。脑电样本量小,你在ImageNet上搬一个50层的ResNet过来,几乎百分之百过拟合,训练集准确率逼近100%,验证集一塌糊涂。
我采用的基准结构如下表。输入尺寸按频率点32、时间点64、通道数3来设计,对应大约4秒数据、1~40Hz频段。
| 层 | 输出尺寸 | 说明 |
|---|---|---|
| 输入层 | 32×64×3 | 频率×时间×通道 |
| Conv2D 3×3,32 | 32×64×32 | ReLU,padding=same |
| BatchNorm | 32×64×32 | 稳定训练 |
| MaxPool 2×2 | 16×32×32 | 降维 |
| Conv2D 3×3,64 | 16×32×64 | ReLU,padding=same |
| BatchNorm | 16×32×64 | 稳定训练 |
| MaxPool 2×2 | 8×16×64 | 降维 |
| Conv2D 3×3,128 | 8×16×128 | ReLU,padding=same |
| BatchNorm | 8×16×128 | 稳定训练 |
| GlobalAvgPool | 128 | 全局平均池化 |
| Dense 64 | 64 | ReLU |
| Dropout | 64 | 丢弃率0.5 |
| Dense 3 | 3 | Softmax |
GlobalAvgPool这段设计是我特别想强调的。它把空间信息收敛到一个128维向量,相比直接Flatten,参数量少很多,而且天然具备一定的平移鲁棒性。配合Dropout和早停,这个结构在样本量不大的脑电数据上表现得相当稳定。
4.3 训练配置:数据划分、损失函数、优化策略
模型训练配置方面,我踩过的坑主要是数据划分方式,这部分直接影响结果可信度,必须单独说明。脑电试次之间不是严格独立的,相邻试次的时间间隔很短,信号模式可能高度相关。如果按试次随机打乱后划分训练集和验证集,验证集里会出现大量和训练集样本特征几乎一样的邻居样本,验证准确率会比真实泛化性能高出好几个百分点。
我的做法是:按采集批次或时间段划分。比如前百分之六十的采集时间内的试次做训练集,后百分之四十做验证集,或者采用多次分层划分。这样能很大程度减少数据泄漏的影响。这个策略比调任何模型参数都重要,说得直白一点,如果你发现验证集准确率很高但线下实验效果很差,多半就是划分方式出了问题。
优化器选择Adam,初始学习率0.001,批大小64。损失函数用的是加权交叉熵,因为三类样本数量并不均衡,空闲状态通常容易采集,数量多,而左手、右手想象试次相对少。如果不加权,模型只需要把所有样本都判成多数类,准确率也能有不错的数字。权重我按各类样本数的反比设置,让少数类错分的代价更高。
训练轮次上限50轮,配合早停策略,耐心值设为12个epoch。也就是说,验证集准确率连续12轮没有上升就停止训练,并回滚到最佳模型。再加上固定随机种子,每次训练的结果差异能控制在一个合理范围内,否则同一个模型跑两次,准确率波动1~2个百分点,会让人完全没法判断改动到底有没有效果。
4.4 我的消融实验:小波时频图 vs 原始波形 vs FFT频谱
为了验证小波时频图这条路线的价值,我设计了三组对比实验。第一组输入原始时域波形,把每段数据下采样成固定长度的一维序列,用一维CNN处理;第二组输入FFT频谱,每通道算出一维幅度谱,堆叠成二维输入,用二维CNN处理;第三组就是本文的CWT时频图,用前面定义的二维CNN处理。
网络参数量尽量保持在同一量级,训练配置完全一样。结果如下(只代表我这批数据上的情况,换数据不会完全吻合,但趋势有参考价值)。
| 输入形态 | 验证准确率 | 备注 |
|---|---|---|
| 原始时域波形 + 1D CNN | 62%左右 | 模型容易波动 |
| FFT频谱 + 2D CNN | 68%左右 | 丢失时间定位信息 |
| CWT时频图 + 2D CNN | 78%左右 | 频率和时间信息共存 |
从结果上看,CWT相对FFT的提升很直观,这说明"什么时候出现这个频率成分"确实是对分类有用的信息,单纯把时间平均掉是有损失的。而时域波形输入效果最差,原因也不难理解,原始EEG里的节律信息淹没在背景噪声里,让CNN直接从原始信号里学出频率特征,需要的样本量和训练轮次远远超出了我们手头的预算。
5. 这一路踩过的坑,和几个救命的实操细节
5.1 尺度算错频率轴,整个输入都是错的
第一个大坑,也是我浪费最多时间的事情,是CWT的尺度换算问题。我最初在生成时频图时,直接把scales设成np.arange(1, 128),心里想着"反正尺度越多越好",然后在画图时发现频率轴最高好像不到4Hz,所有时频图看起来都是低频一片糊。当时还以为是数据有问题,折腾了很久。
后来才发现,问题出在采样周期和尺度之间的换算。如果你没有传sampling_period,pywt返回的freqs是归一化频率,必须乘以采样率才能还原成真实频率;如果你传了采样率但忘了传采样周期,或者传的采样周期是导联缩放后的数值,结果都会偏掉一个倍数。建议所有换算都套用pywt.scale2frequency(wavelet, scales) / sampling_period,并且生成之后打印一次真实频率范围核对,确保1~40Hz这个目标区间落在里面。
5.2 随机划分验证集=重复验证,结果虚高
这个坑前面提过,但它重要到值得再展开一次。脑电数据天然具有时间连续性,同一个被试同一批次采集的信号,前后试次之间的特征高度相似。随机打乱划分,相当于测试集里混入了训练集的"近亲",模型做的是特征记忆,不是泛化学习。
我当时换了一种更稳妥的评估方案:按时间段分块,把一整段采集过程切成时间上不重叠的区间,整块整块地划分。如果数据来自多次采集,就按采集顺序分组,保证验证集试次的采集时间和训练集没有重叠。这样做之后,模型准确率从虚高的85%回落到真实水平的78%左右,虽然数字下降了,但这个数字才真正有意义。
5.3 类别不平衡、过拟合与复现性
类别不平衡问题在脑电分类里几乎必然存在。我最初的模型在测试集上总体准确率看起来有72%,但一查混淆矩阵,发现左手想象这一类准确率只有40%多,绝大多数都被判成了空闲状态。这是因为空闲状态天然样本多、特征分散,模型学成了"只要不确定就判空闲"。加权损失加上按类别计算F1分数之后,模型的真实短板才暴露出来,我也才真正开始关注少数类特征。
过拟合则是样本量小带来的宿命。我的应对手段包括:Dropout从0.3改成0.5、加weight decay、减少卷积核数量、强制做图像归一化。还有一个细节是随机种子固定问题,如果不固定种子,环境差异会在训练过程中不断放大,模型可能跑三次出三组偏差不小的结果。固定所有可以固定的随机源,比如numpy、random、PyTorch或者TensorFlow各自的种子,是让实验可复现的第一步。
5.4 如果以后要上实时系统,还要解决什么
实验室跑通和实时系统落地之间的距离,比想象中大得多。在离线实验里,我们可以在一个试次结束后慢慢生成CWT时频图,再送进CNN,时间长一点无所谓。但实时运动想象系统要求的是低延迟,理论上从脑电到命令输出应该控制在几百毫秒量级。
时间开销最大的部分是CWT的逐尺度卷积计算。如果你想做实时版本的方案,我建议两个方向:一是缩小尺度数量,只保留最关注的频段和最具分辨力的尺度步长;二是把CWT计算改成用GPU并行处理,或者在嵌入式设备上用提前算好的小波滤波器组代替逐点卷积。模型部分反而是相对轻量的,轻量CNN的一次前向推理耗时通常远小于时频图生成时间。
从应用层面来看,需要注意的还有跨session(不同日采集)的稳定性。脑电特征在不同时间变化很大,今天训练的模型,过一周换上新数据,准确率可能明显下降。这时候要么做跨session数据扩增,要么做小规模的在线自适应微调。这是一个值得深入的方向。
回头看我这一整套流程,最深的体会是:模型结构的作用被很多人高估了,信号处理和输入表达的作用被低估了。小波变换和CNN的配合之所以有效,不是因为模型更复杂,而是因为它让模型看到了"时间+频率"的完整信息。希望这篇分享能帮你在运动想象分类的路上少走几个弯路。