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 环境下写出来的。正确写法是带上encodinglatin1读出来是一个字典里面两个键最要紧data的形状是(40, 40, 8064)labels的形状是(40, 4)。前者对应「每个被试 40 个 trial、40 个通道、8064 个采样点」后者是 valence、arousal、dominance、liking 四个 1 到 9 的主观量表分。8064 这个数字值得单独说一句。官方采样率是 128 Hz8064 除以 128 正好是 63 秒。但真正的刺激呈现只有 60 秒前面 3 秒是静息基线。也就是说每个 trial 的前 384 个采样点是基线不是情绪诱发段。我见过太多代码直接对整段 8064 点做特征结果标签被基线稀释被试内准确率卡在 60% 出头怎么调都上不去。切掉前 384 点剩下的 7680 点才是你要的东西。40 个通道里前 32 个是 EEG后 8 个是外围生理信号眼电、肌电、皮电、呼吸、体积描记、体温一类。如果你不做多模态融合只取前 32 通道。这里有个隐性坑通道顺序必须显式写死成一个列表而不是靠下标去猜。不同整理版本的通道排列偶尔会有出入靠data[:, :32, :]取数没问题但一旦你要按脑区做左右半球对比或者做通道级特征选择顺序错了整个分析就是错的。标签二值化是第二个决策点。量表是 1 到 95 是中性。常见做法是大于 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, encodinglatin1) # 关键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, thr5.0, drop_neutralTrue): valence 二值化thr 记 1thr 记 0thr 视配置丢弃 v labels[:, 0] if drop_neutral: keep v ! thr y (v[keep] thr).astype(np.int64) return keep, y return np.ones(len(v), dtypebool), (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是两个互相牵制的开关thr5.0加drop_neutralTrue是最保守的用法样本量会少一点但标签更干净换成thr4.5或thr5.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 Hzgamma 段只能取到 30 到 45 Hz而且边缘衰减明显实际可用信息有限SEED-IV 频带更宽gamma 可以取到 50 Hz。这一点在写论文时要说清楚不能拿 DEAP 的「gamma 特征无效」去否定 SEED-IV 上的 gamma。3.4 标准化与划分被试内和被试间是两件不同的事在所有可复现细节里这一条最容易被忽略也最容易让结果失真。被试内评估是用同一个被试的部分 trial 训练、另一部分 trial 测试。这种设置下模型见过这个人的脑电统计特性准确率通常很高但它回答的问题是「这个人自己的数据能不能分类」不是「换个人还能不能用」。跨被试评估是留一个被试完全不出现在训练集里测试时才用他的数据。准确率通常掉 15 到 30 个百分点这才是真实可用的场景。两种评估对应的数据划分代码完全不同而在论文里如果不写清楚是哪一种读者就会误读。后面的完整脚本里我用LeaveOneGroupOutgroups 传被试编号这是跨被试的标准写法。标准化的位置同样关键。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 控制单个样本的影响半径越大边界越曲折。gammascale是个合理的默认值它按特征数自动缩放比写死 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(kernelrbf, class_weightbalanced)) ]) # 内层搜索空间C 跨三个数量级gamma 用 scale 加两个量级 param_grid { svm__C: [0.1, 1, 10, 100], svm__gamma: [scale, 0.01, 0.001], } inner GridSearchCV(pipe, param_grid, cv5, scoringaccuracy, 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_weightbalanced是给不均衡准备的DEAP 二分类在 5 分阈值下通常还算均衡但你要是用了死区或者做四分类类别比例可能到 2:1不加这个参数 SVM 会偏向多数类整体准确率看着不低但少数类召回惨不忍睹。n_jobs-1开满核注意它和GridSearchCV内部并行叠加时可能把内存吃满样本量特别大时改成n_jobs4更稳。结果解读上不要只看平均值。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或LeaveOneGroupOutgroups 传 trial 编号或被试编号。重叠窗本身没问题问题出在划分粒度上。5.5 被试内结果当成跨被试泛化现象论文里写「准确率 94.5%」读者默认这是可部署的性能实际复现时用新被试只能到 70%。原因被试内评估下模型见过这个人的全部数据分布本质上是在做被试特定的模式识别。这和「戴上一个新设备就能用」是两回事。有些工作甚至在同一被试的同一段数据上既训练又测试只是切了不同的时间窗这种数字基本没有参考价值。解决报告结果时明确标注评估协议——被试内、跨 session、跨被试三者不能混。如果只做了被试内就在结论里写清楚适用边界。真正想做可部署的方案跨被试是必须过的一关而且通常需要配合域适应或者被试归一化。6. 让数字站得住消融、检验与写作口径跑出第一版结果之后我一般会先做一轮消融确认每个环节到底贡献了多少。不是为了堆表格是为了知道自己这套流程里哪一步是必需的、哪一步可以砍掉。6.1 消融表怎么设计消融的维度不用多四个就够特征类型DE 对比功率谱、频段组合五频段对比只用 alphabeta、窗长1 秒对比 2 秒、评估协议被试内对比跨被试。每一项只改一个变量其余保持完全一致包括随机种子。消融项改动内容关注点特征类型DE 换成频段功率对数变换带来的增益频段组合五频段压到 alphabeta低频段是否真有必要窗长1 秒改成 2 秒样本量与频率分辨率的权衡评估协议被试内换成跨被试泛化代价有多大表格里的「关注点」写的是你要回答的问题不是预期结果。如果某项消融后准确率几乎没变那这一项在你的方案里就是冗余的论文里可以少写一段代码里可以少算一半。6.2 配对检验与置信区间只报平均值和标准差不够。跨被试评估下每个被试一个准确率样本量只有 15 或 32标准差本身的估计就不稳。我习惯补两样东西一是逐被试的准确率全列出来让读者自己看分布二是做配对检验比较两个方案在同一批被试上的差异是否显著。配对检验用 Wilcoxon 符号秩检验比 t 检验稳因为准确率分布不一定正态样本量也小。跑完之后报 p 值和效应量别只报 p 值。置信区间用 bootstrap 重采样被试级别1000 次足够别在原样本级别重采样那样区间会窄得离谱。写论文的时候我最常犯也最常看到的一个毛病是把最好那次的超参和最好那次的随机种子写进正文其余的都丢掉。这个习惯短期能让数字好看长期会让自己都不信自己的结果。我现在的做法是固定种子、固定网格跑完之后不管结果好坏都留着写的时候如实报。这个习惯没什么技巧就是省下日后被自己坑的后悔药。希望帮到你。本文还有配套的精品资源点击获取