EEG情绪识别:DEAP/SEED-IV的SVM特征工程与评估避坑
简介:这份资源是一篇围绕脑电信号情绪识别的研究论文,面向情感计算、脑机接口方向的高校学生与科研入门者,尤其适合做毕业设计或课题复现的读者参考,用于理解公开数据集上的情绪检测流程与可行性验证思路。压缩包内为 1 个 PDF 文件,大小约 701KB,即论文全文,包含摘要、方法、特征提取与分类结果等完整章节,篇幅紧凑便于通读与引用。论文以 DEAP 与 SEED-IV 两个公开 EEG 数据库为对象,先对预处理信号施加离散小波变换,划分出 5 个频段,再提取功率、能量、微分熵及部分时域特征;模型侧采用按通道构建的 SVM 分类器,并通过通道组合器融合结果,在二维维度模型下判定内心情绪状态,四类分类准确率在 DEAP 上为 74%、86%、72%、84%,在 SEED-IV 上为 79%、76%、77%、74%。文末还梳理了基于 EEG 的情绪检测相关研究工作与 BCI 应用背景,可作为方法对比与实验设计的参照。目前已有 347 人学习。
1. 从一段 60 秒脑电到三分类情绪标签:这个题目到底在做什么
有个做可穿戴的同行问我:同样是基于 EEG 的情绪检测,DEAP 和 SEED-IV 这两个公开库,为什么有人报 95%,有人连 60% 都摸不到?我看了几份代码,差距基本不在分类器,而在两件事——特征是怎么提的,数据是怎么划分的。这个题目说白了就是:把头皮上采到的多通道电压时间序列,切成固定长度的窗口,从每个窗口里抽出频域或熵类特征,再用 SVM 把窗口判成「高兴/悲伤/恐惧/平静」或者「高效价/低效价」。SVM 在今天算不上先进,但它训练快、对几百到几千个样本的小数据集友好、决策边界可解释,仍然是这个方向上绕不开的基线。后面按读数据、预处理、提特征、训模型、避坑、把数字写扎实的顺序走一遍,代码可以直接跑。
2. DEAP 与 SEED-IV 的数据结构差异:先读懂数据再谈模型
2.1 DEAP 的 40 通道、63 秒与 1-9 量表
DEAP 最容易被低估的一步是打开文件。它给的是 pickle 序列化的.dat,不是常见的.mat或.edf,直接pickle.load会报编码错误,因为文件是在 Python 2 环境下写出来的。正确写法是带上encoding='latin1',读出来是一个字典,里面两个键最要紧:data的形状是(40, 40, 8064),labels的形状是(40, 4)。前者对应「每个被试 40 个 trial、40 个通道、8064 个采样点」,后者是 valence、arousal、dominance、liking 四个 1 到 9 的主观量表分。
8064 这个数字值得单独说一句。官方采样率是 128 Hz,8064 除以 128 正好是 63 秒。但真正的刺激呈现只有 60 秒,前面 3 秒是静息基线。也就是说每个 trial 的前 384 个采样点是基线,不是情绪诱发段。我见过太多代码直接对整段 8064 点做特征,结果标签被基线稀释,被试内准确率卡在 60% 出头怎么调都上不去。切掉前 384 点,剩下的 7680 点才是你要的东西。
40 个通道里,前 32 个是 EEG,后 8 个是外围生理信号(眼电、肌电、皮电、呼吸、体积描记、体温一类)。如果你不做多模态融合,只取前 32 通道。这里有个隐性坑:通道顺序必须显式写死成一个列表,而不是靠下标去猜。不同整理版本的通道排列偶尔会有出入,靠data[:, :32, :]取数没问题,但一旦你要按脑区做左右半球对比或者做通道级特征选择,顺序错了整个分析就是错的。
标签二值化是第二个决策点。量表是 1 到 9,5 是中性。常见做法是大于 5 记为高效价、小于 5 记为低效价、等于 5 的样本丢掉。也有人用 4.5 和 5.5 做死区,把中间段全部剔除,代价是样本量掉一截。如果要做四分类(高效价高唤醒、高效价低唤醒、低效价高唤醒、低效价低唤醒),样本会迅速变得不均衡,后面 5.1 节会讲到这个坑。
2.2 SEED-IV 的 62 通道、三 session 与逐秒标签
SEED-IV 的组织方式和 DEAP 完全是两套逻辑。它是 15 个被试,每人 3 个 session,每个 session 24 个 trial,四类情绪(平静、悲伤、恐惧、高兴)各 6 个 trial。通道数 62,覆盖全脑,原始采集采样率比 DEAP 高得多,官方提供的预处理版本已经做过降采样,实际拿到手的数据采样率以文件里的元信息为准,代码里不要写死一个想当然的值。
真正让新手翻车的是标签粒度。SEED-IV 提供的标签不是「一个 trial 一个类标」,而是和采样点对齐的标签序列——每个采样点(或者每秒)都有一个 0 到 3 的类别值。这意味着两种用法都成立:按 trial 取众数得到整段标签,或者按固定窗口切分后取窗口内的标签。两种都能跑,但绝对不能在同一个实验里混用,否则你会得到一份看起来正常、实际上标签整体偏移了几百个采样点的数据。
还有一个 DEAP 没有的特性:三个 session 是不同时间段采集的。同一个被试,session 1 和 session 3 之间的信号分布漂移可能比不同被试之间还大。这就决定了 SEED-IV 上做跨 session 评估,准确率会比 session 内评估掉一大截,掉多少取决于你用的特征稳不稳定。微分熵之所以在这个库上被反复使用,很大程度上就是它对这种漂移相对不敏感。
2.3 把两套数据读成统一张量的最小代码
不管后面用什么模型,先把两份数据整理成同一种形状:(n_epoch, n_channel, n_time)。这样特征提取函数可以复用一份。
import pickle import numpy as np import scipy.io as sio DEAP_FS = 128 DEAP_BASE = 3 * DEAP_FS # 前 3 秒基线,384 点 DEAP_EEG_CH = 32 # 前 32 通道为 EEG def load_deap_one(path): """读取单个被试的 DEAP .dat,返回去掉基线的 EEG 与四维标签""" with open(path, 'rb') as f: sub = pickle.load(f, encoding='latin1') # 关键:latin1 兼容 Py2 序列化 eeg = sub['data'][:, :DEAP_EEG_CH, DEAP_BASE:] # (40, 32, 7680) labels = sub['labels'] # (40, 4) 1-9 量表 return eeg.astype(np.float32), labels def deap_to_binary(labels, thr=5.0, drop_neutral=True): """valence 二值化:>thr 记 1,<thr 记 0,==thr 视配置丢弃""" v = labels[:, 0] if drop_neutral: keep = v != thr y = (v[keep] > thr).astype(np.int64) return keep, y return np.ones(len(v), dtype=bool), (v > thr).astype(np.int64) def load_seediv_mat(path): """读取 SEED-IV 的 mat,键名先打印再取,避免版本差异踩空""" m = sio.loadmat(path) keys = [k for k in m.keys() if not k.startswith('__')] eeg_key = next(k for k in keys if 'eeg' in k.lower() and 'label' not in k.lower()) lab_key = next(k for k in keys if 'label' in k.lower()) eeg = m[eeg_key] # 常见形状 (n_ch, n_time) lab = m[lab_key].ravel() # 与采样点对齐的 0-3 标签 assert eeg.shape[1] == lab.shape[0], f'长度不匹配: {eeg.shape} vs {lab.shape}' return eeg.astype(np.float32), lab.astype(np.int64)load_deap_one里两个参数要盯住:DEAP_BASE决定切掉多少基线,DEAP_EEG_CH决定保留多少通道。如果你后来想加外围信号做融合,把DEAP_EEG_CH调大即可,但记得特征提取那段要跟着改。deap_to_binary里的thr和drop_neutral是两个互相牵制的开关:thr=5.0加drop_neutral=True是最保守的用法,样本量会少一点但标签更干净;换成thr=4.5或thr=5.5就变成了死区,样本量掉得更多但类别可分性通常更好。
load_seediv_mat里我故意没有写死键名,而是先扫描再匹配。这不是偷懒,是因为不同来源的整理版本键名确实不一样,写死一个键名换台机器就跑不起来。里面的assert是必须的:EEG 和标签长度对不上,说明这份 mat 里 EEG 已经做过降采样而标签没有,或者反过来,这种情况下任何后续分析都没有意义,早点报错比跑完发现不对强。
3. 预处理与特征工程:SVM 之前真正决定上限的一步
3.1 带通、陷波、重参考:两个库的滤波策略不一样
DEAP 发布的数据已经做过带通预处理,大致在 3 到 45 Hz 之间。这意味着你在它上面再叠一轮 1 到 50 Hz 的带通,边际收益很小,反而可能因为滤波器阶数过高引入相位失真。我的习惯是:DEAP 上只做一次 50 Hz 陷波(国内工频),再考虑要不要重参考;SEED-IV 上则老老实实做 1 到 50 Hz 带通加陷波,因为它的原始频带更宽,低频漂移和高频肌电都还在。
重参考这件事在两个库上的处理也不一样。DEAP 是 32 通道,覆盖偏额颞,做全脑平均参考会让靠近边缘的通道信噪比变差;SEED-IV 是 62 通道全脑覆盖,平均参考或者乳突参考都合理。这条不是玄学,是通道布局决定的:参考电极离得越远、通道覆盖越均匀,平均参考越安全。如果拿不准,就跑两组对比,看被试内准确率的差异是否稳定,别凭感觉定。
滤波器的实现细节也值得说一句。用scipy.signal.filtfilt做零相位滤波,不要用lfilter。前者正反各滤一次,相位不偏移;后者会引入群延迟,虽然对功率类特征影响不大,但你要做时间对齐或者跨通道相关分析时就会出问题。滤波器阶数取 4 阶足够,阶数再高过渡带更陡,但数值稳定性下降,在 128 Hz 这种低采样率上尤其明显。
3.2 分窗与标签继承:60 秒切成 1 秒窗之后样本量是多少
这是整个流程里对最终数字影响最大的一步,也是最容易被草率处理的一步。
DEAP 每个 trial 去掉基线后剩 60 秒。如果按 1 秒窗、1 秒步长切,每个 trial 得到 60 个样本,40 个 trial 就是 2400 个样本每被试,32 个被试加起来七万多个。这个量级对 SVM 来说完全够用。但注意标签继承:这 60 个样本共享同一个 trial 的情绪标签。也就是说同一 trial 切出来的窗口高度相关,如果你在划分训练集和测试集时按样本随机打乱,同一 trial 的窗口会同时出现在训练和测试里,准确率会被抬得虚高——这个坑在第 5 章会单独讲。
SEED-IV 每个 trial 大约两分钟,1 秒窗切下来一百多个样本,24 个 trial 就是两三千个样本每 session。样本量看着更充裕,但它的标签是逐点的,切窗时取窗口内标签的众数更稳妥,前提是窗口内标签基本一致。如果某个窗口跨了两个类别(在 trial 边界附近会发生),直接丢掉比强行取众数干净。
窗口长度也是可调参数。1 秒窗是常见起点,短于 0.5 秒时低频段(delta、theta)几乎无法估计,长于 4 秒时样本量掉得厉害而且情绪状态可能在窗口内已经变化。我一般会在 1 秒和 2 秒之间各跑一遍,看准确率的差异是否稳定,稳定就选样本量大的那个。
3.3 微分熵:为什么它在 SEED-IV 上比单纯功率谱更抗造
微分熵(DE)在这条技术路线上几乎是标配特征,原因不复杂。对一个近似高斯分布的带限信号,微分熵有闭式解:
DE = 0.5 × ln(2πeσ²)
σ² 是该频段信号的方差。也就是说,你根本不需要做 FFT 再积分功率谱,只要把信号滤到目标频段、算方差、套公式就行。计算量小、数值稳定、对窗长不敏感,这三点让它在小样本场景下特别实用。
更关键的是它对数性质。功率谱的数值跨度可能有好几个数量级,直接喂给 SVM 会被少数大值通道主导;取了对数之后,分布被压到一个温和的区间,标准化之后各通道各频段的量纲基本一致。这也是为什么很多人发现「换成 DE 之后准确率一下子上去了」——不一定是 DE 本身信息量更大,而是它让 SVM 的 RBF 核更好工作。
频段划分上,DEAP 因为已经带通到 45 Hz,gamma 段只能取到 30 到 45 Hz,而且边缘衰减明显,实际可用信息有限;SEED-IV 频带更宽,gamma 可以取到 50 Hz。这一点在写论文时要说清楚,不能拿 DEAP 的「gamma 特征无效」去否定 SEED-IV 上的 gamma。
3.4 标准化与划分:被试内和被试间是两件不同的事
在所有可复现细节里,这一条最容易被忽略,也最容易让结果失真。
被试内评估是:用同一个被试的部分 trial 训练、另一部分 trial 测试。这种设置下,模型见过这个人的脑电统计特性,准确率通常很高,但它回答的问题是「这个人自己的数据能不能分类」,不是「换个人还能不能用」。跨被试评估是:留一个被试完全不出现在训练集里,测试时才用他的数据。准确率通常掉 15 到 30 个百分点,这才是真实可用的场景。
两种评估对应的数据划分代码完全不同,而在论文里如果不写清楚是哪一种,读者就会误读。后面的完整脚本里我用LeaveOneGroupOut,groups 传被试编号,这是跨被试的标准写法。
标准化的位置同样关键。StandardScaler必须只在训练折上fit,测试折只做transform。如果先对全量数据fit_transform再切分,测试集的均值方差信息就泄漏进训练过程了,尤其在跨被试设置下,泄漏的量级不小。用sklearn的Pipeline把标准化和 SVM 串起来,配合交叉验证的折内 fit,是唯一不会出错的方式。
4. 用 SVM 跑通基线:C、gamma 与交叉验证怎么定
4.1 线性核还是 RBF:先算清楚特征维度和样本量
特征维度很好算:DEAP 是 32 通道 × 5 频段 = 160 维;SEED-IV 是 62 通道 × 5 频段 = 310 维。样本量方面,DEAP 二分类在 1 秒窗下每被试几千个样本,SEED-IV 每 session 也是几千。
这个比例下,线性核和 RBF 核都值得试。经验上:特征维度远小于样本量、且特征经过标准化时,线性核往往不输 RBF,而且训练快、超参少;一旦类别边界是非线性的(比如高效价高唤醒和低效价低唤醒在频域上不是简单线性可分),RBF 会明显更好。我一般先跑线性核拿到一个基线数字,再跑 RBF 看提升幅度,如果提升在 5 个百分点以内,我更倾向保留线性核——它的可解释性好得多,可以直接看权重判断哪些通道哪些频段在起作用。
RBF 有两个关键超参:C 和 gamma。C 控制对误分类的惩罚,越大越容易过拟合;gamma 控制单个样本的影响半径,越大边界越曲折。gamma='scale'是个合理的默认值,它按特征数自动缩放,比写死 0.01 这种拍脑袋值稳。
4.2 嵌套交叉验证:网格搜索放在哪一层才不泄漏
这是很多人写不对的地方。外层是评估用的划分(比如留一被试),内层是调参用的划分(比如 5 折)。网格搜索必须放在内层,只在外层的训练集上做,选出的最优参数再去预测外层测试集。
如果反过来——先用全量数据网格搜索选出最优 C 和 gamma,再用这个参数做留一被试交叉验证——那测试集的信息已经通过超参选择泄漏了。这样报出来的数字会偏高,而且偏高的幅度随搜索网格变密而变大。审稿人问一句「你的超参是怎么选的」就露馅。
嵌套的代价是计算量翻倍。留一被试在 15 个被试上要训 15 次,每次内层 5 折网格搜索假设 12 组参数,就是 15 × 5 × 12 = 900 次训练。单次 SVM 在几千样本上也就几秒,总共半小时到一小时,可以接受。
4.3 一份能直接跑的完整训练脚本与结果解读
下面这段把前面所有环节串起来,假设X已经是特征矩阵、y是标签、groups是被试编号。
import numpy as np from sklearn.pipeline import Pipeline from sklearn.preprocessing import StandardScaler from sklearn.svm import SVC from sklearn.model_selection import GridSearchCV, LeaveOneGroupOut from sklearn.metrics import accuracy_score, confusion_matrix # 标准化和 SVM 串成 Pipeline,保证 scaler 只在训练折 fit pipe = Pipeline([ ('sc', StandardScaler()), ('svm', SVC(kernel='rbf', class_weight='balanced')) ]) # 内层搜索空间:C 跨三个数量级,gamma 用 scale 加两个量级 param_grid = { 'svm__C': [0.1, 1, 10, 100], 'svm__gamma': ['scale', 0.01, 0.001], } inner = GridSearchCV(pipe, param_grid, cv=5, scoring='accuracy', n_jobs=-1) logo = LeaveOneGroupOut() accs, all_true, all_pred = [], [], [] for tr, te in logo.split(X, y, groups): inner.fit(X[tr], y[tr]) # 只用训练折调参 pred = inner.predict(X[te]) # 测试折完全不参与任何 fit accs.append(accuracy_score(y[te], pred)) all_true.extend(y[te]); all_pred.extend(pred) print(f'跨被试平均准确率: {np.mean(accs):.4f} ± {np.std(accs):.4f}') print(f'逐被试最低/最高: {np.min(accs):.4f} / {np.max(accs):.4f}') print('混淆矩阵:') print(confusion_matrix(all_true, all_pred))几个参数需要盯住。class_weight='balanced'是给不均衡准备的,DEAP 二分类在 5 分阈值下通常还算均衡,但你要是用了死区或者做四分类,类别比例可能到 2:1,不加这个参数 SVM 会偏向多数类,整体准确率看着不低但少数类召回惨不忍睹。n_jobs=-1开满核,注意它和GridSearchCV内部并行叠加时可能把内存吃满,样本量特别大时改成n_jobs=4更稳。
结果解读上,不要只看平均值。np.std(accs)反映的是被试间差异,如果标准差接近 0.15,说明某些被试几乎分不开,这时候平均值没有太大意义,得去看是哪些被试、他们的数据有什么共同点(比如标签分布偏移、信号质量差)。混淆矩阵要按类别看,二分类下如果两类召回率差 20 个百分点以上,基本可以确定是类别不均衡或者特征对某一类不敏感,不是模型没调好。
5. 避坑清单:五个最容易翻车的地方
5.1 前 3 秒基线没切,标签被稀释
现象:被试内准确率稳定在 60% 到 65%,换特征、换核函数、调 C 和 gamma 都没什么变化,像是撞了一堵墙。
原因:DEAP 的 8064 点里前 384 点是静息基线,情绪诱发还没开始。基线段的频域特征和情绪段差别很大,混在一起相当于给每个样本掺了 5% 的噪声标签。更麻烦的是这个噪声对所有 trial 是同向的,SVM 学到的是一个折中的边界。
解决:切数据时就把DEAP_BASE减掉,只保留后 7680 点。如果实在想用基线段,正确的用法是做基线校正——用基线段的均值或功率去归一化情绪段,而不是把它当成情绪样本喂进去。
5.2 标准化 fit 在全量数据上
现象:交叉验证报出 90% 以上,换个随机种子数字波动很小,看起来非常稳。但真正拿新被试数据一测,掉到 70% 以下。
原因:StandardScaler在切分之前对全量数据做了fit_transform,测试集的均值和方差信息进入了训练过程。在跨被试场景下这个泄漏尤其严重,因为不同被试的信号幅度差异本来就大,scaler 实际上是在帮模型做被试间的对齐,而这个对齐在真实部署时是拿不到的。
解决:一律用Pipeline,让 scaler 跟着交叉验证的折走。手工写循环时,fit只传训练折,transform分别作用于训练折和测试折。这条没有例外。
5.3 通道顺序对不上还硬拼特征
现象:把 DEAP 和 SEED-IV 的特征拼在一起做联合训练,或者把两个来源的数据混着评估,准确率异常高或者异常低,混淆矩阵的形状也很怪。
原因:两个库的通道数不同(32 对 62),通道命名体系和排列顺序也不同。按位置硬拼,等于把额区和枕区的信号对错了号。如果还叠加了不同的参考方式,问题会更隐蔽——数字能跑出来,但物理意义完全错位。
解决:要么分别建模、只在结果层面比较,要么先做通道映射,把两个库都投影到同一套 10-20 标准电极上,取交集通道。做映射时把映射表写进代码常量,别靠记忆。
5.4 重叠窗切分把准确率抬高十个点
现象:把 1 秒窗、0.5 秒步长(50% 重叠)切出来的样本随机打乱后做 5 折交叉验证,准确率比不重叠时高 8 到 12 个百分点。
原因:重叠窗意味着相邻样本共享一半的原始信号,它们高度相关。随机打乱后,训练集里的某个窗口和测试集里的某个窗口可能只差半个窗长,模型实际上是在做近似记忆。这属于样本级泄漏,比特征级泄漏更难发现,因为代码逻辑看起来完全正常。
解决:划分的单位必须是 trial 或被试,不能是窗口。用GroupKFold或LeaveOneGroupOut,groups 传 trial 编号或被试编号。重叠窗本身没问题,问题出在划分粒度上。
5.5 被试内结果当成跨被试泛化
现象:论文里写「准确率 94.5%」,读者默认这是可部署的性能,实际复现时用新被试只能到 70%。
原因:被试内评估下,模型见过这个人的全部数据分布,本质上是在做被试特定的模式识别。这和「戴上一个新设备就能用」是两回事。有些工作甚至在同一被试的同一段数据上既训练又测试,只是切了不同的时间窗,这种数字基本没有参考价值。
解决:报告结果时明确标注评估协议——被试内、跨 session、跨被试,三者不能混。如果只做了被试内,就在结论里写清楚适用边界。真正想做可部署的方案,跨被试是必须过的一关,而且通常需要配合域适应或者被试归一化。
6. 让数字站得住:消融、检验与写作口径
跑出第一版结果之后,我一般会先做一轮消融,确认每个环节到底贡献了多少。不是为了堆表格,是为了知道自己这套流程里哪一步是必需的、哪一步可以砍掉。
6.1 消融表怎么设计
消融的维度不用多,四个就够:特征类型(DE 对比功率谱)、频段组合(五频段对比只用 alpha+beta)、窗长(1 秒对比 2 秒)、评估协议(被试内对比跨被试)。每一项只改一个变量,其余保持完全一致,包括随机种子。
| 消融项 | 改动内容 | 关注点 |
|---|---|---|
| 特征类型 | DE 换成频段功率 | 对数变换带来的增益 |
| 频段组合 | 五频段压到 alpha+beta | 低频段是否真有必要 |
| 窗长 | 1 秒改成 2 秒 | 样本量与频率分辨率的权衡 |
| 评估协议 | 被试内换成跨被试 | 泛化代价有多大 |
表格里的「关注点」写的是你要回答的问题,不是预期结果。如果某项消融后准确率几乎没变,那这一项在你的方案里就是冗余的,论文里可以少写一段,代码里可以少算一半。
6.2 配对检验与置信区间
只报平均值和标准差不够。跨被试评估下,每个被试一个准确率,样本量只有 15 或 32,标准差本身的估计就不稳。我习惯补两样东西:一是逐被试的准确率全列出来,让读者自己看分布;二是做配对检验,比较两个方案在同一批被试上的差异是否显著。
配对检验用 Wilcoxon 符号秩检验比 t 检验稳,因为准确率分布不一定正态,样本量也小。跑完之后报 p 值和效应量,别只报 p 值。置信区间用 bootstrap 重采样被试级别,1000 次足够,别在原样本级别重采样,那样区间会窄得离谱。
写论文的时候,我最常犯也最常看到的一个毛病是:把最好那次的超参和最好那次的随机种子写进正文,其余的都丢掉。这个习惯短期能让数字好看,长期会让自己都不信自己的结果。我现在的做法是固定种子、固定网格,跑完之后不管结果好坏都留着,写的时候如实报。这个习惯没什么技巧,就是省下日后被自己坑的后悔药。希望帮到你。
本文还有配套的精品资源,点击获取