资讯详情 SEED数据集EEG情绪识别:差分熵特征提取与SVM验证全流程
📅 2026/10/10 1:30:02
简介面向脑机接口与情感计算学习者的EEG情绪识别源码项目基于公开SEED脑电数据集完成情绪分类适用于毕业设计、期末大作业与课程设计等实践环节。源码已经本地编译并运行通过评审得分98分项目难度适中内容经由助教审定可放心参考或二次扩展。整个压缩包共17个文件以Python脚本4个、XML配置7个为主同时配有TXT与Markdown说明文档、DOCX格式的结果记录以及TensorBoard日志文件便于查看运行方式、实验结论与训练过程包体整体约10.65MB目录结构清晰。目前已有318人学习下载具备一定参考热度。源码内含CNN与SVM两条情绪识别实现路线覆盖脑电数据预处理、特征处理、模型训练与结果统计等环节并附有真实运行记录能帮助学习者快速复现SEED情绪识别实验理解脑电特征与分类模型的实际配合方式也为后续更换数据集、改进网络结构或扩展对比实验提供了可运行的基线工程。1. 做 EEG 情绪识别的人第一个绕不开的坑就是 SEED 数据集做 EEG 情绪识别的人第一站几乎都是 SEED 数据集。真正把这个源码项目跑通、并且跑出可信精度的人却远没有想象中多——问题通常不在算法难度而在对数据结构、特征计算和验证方式的理解。很多人拿到数据后直接读 .mat、抽特征、套一个分类器随机划分跑到 90% 以上就以为稳了换到跨被试验证立刻掉到 70% 附近于是开始怀疑模型选错实际上更可能踩了数据泄漏的坑。这篇笔记按一个能直接复现的路径走一遍数据集怎么读、标签怎么对齐、差分熵特征怎么算、分类器参数怎么调、验证为什么要用留一被试法最后停在几个真正能拉开分数差距的细节上。适合刚拿到 SEED 数据准备做课程设计或毕业设计的人也适合已经跑通流程但精度一直上不去的熟手。2. 先认识 SEED 数据集从目录结构到标签读取2.1 三次实验与文件命名同一个被试为什么有三个 .matSEED 数据集的采集方式是让被试观看诱发情绪的电影片段然后记录 62 通道头皮脑电。整个数据集不是一次实验采完的而是每个被试在间隔一段时间后重复参加了三轮实验所以你在数据包里会看到同一被试对应三个 .mat 文件命名通常带日期信息。这个设计本意是考察情绪识别模型的时间稳定性但实际使用中很多人把它当成单纯的数据量扩充手段直接把三个文件全部塞进训练集这会带来一个隐蔽的验证问题同一被试的三轮数据在特征分布上高度相似如果不加以区分模型学到的是被试个体特征而不是情绪类别。还有一个更容易被忽略的点每个 .mat 文件的内部结构在不同版本的数据包里可能不一样。有的版本 data 数组形状是 62 × 采样点数有的版本还包含额外字段。我建议拿到文件后先什么都不做用脚本打出来看一下键名和形状再决定后面的处理流程。import scipy.io as sio import h5py path 3_20131027.mat try: mat sio.loadmat(path) print(Keys:, [k for k in mat.keys() if not k.startswith(__)]) print(Type:, type(mat[data])) except ValueError as e: print(loadmat 失败进入 h5py 分支:, e) with h5py.File(path, r) as f: print(HDF5 keys:, list(f.keys()))这段探测代码的作用是快速判断你手里的 .mat 是旧版 MATLAB 格式还是 v7.3 格式。scipy.io.loadmat读 v7.3 会直接抛异常而 h5py 可以读取但读出来的数据是 HDF5 数据集需要先取[()]再转置形状顺序和 MATLAB 维度顺序是反的。这个细节值得在最开始确认因为它决定了后面所有代码的索引方向。2.2 标签文件情绪类别不是写在文件名里的SEED 的标签通常单独放在一个文件夹里和原始脑电文件分离。每个标签文件对应一个被试的一次实验里面的数值是片段编号到情绪类别的映射。很多初学者默认文件列表排序和标签排序一致直接按文件名排序后 zip 成一对这其实是在给自己埋雷。不同操作系统的文件排序规则不同复制移动过文件后顺序更不可靠一旦发生错位误分类率会高得离谱而你自己很难察觉。我习惯的做法是把标签文件解析成一个字典键是片段编号值是情绪类别然后再根据脑电文件名里的片段序号去查。import json def load_label(label_path): raw sio.loadmat(label_path) # 常见的结构是一个二维数组行对应片段序号 label_arr raw[label] label_map {} for i in range(label_arr.shape[0]): label_map[i 1] int(label_arr[i]) return label_map # 用片段编号关联而不是用位置关联 label_map load_label(label_3_20131027.mat) print(前5个片段标签:, {k: label_map[k] for k in range(1, 6)})注意我这里用了i 1作为键是因为很多数据集里片段编号从 1 开始。不同版本可能从 0 开始所以要先用打印出来的 label 值反推一下。情绪类别的数值到底对应正还是负不要凭记忆写死在代码里看一遍官方实验设计文档确认后写到配置字典里后续无论画图还是算准确率都用同一个映射。2.3 通道表62 通道的名称与顺序并不总是按你想象的方式排列SEED 使用的 62 通道脑电帽基于国际 10-20 系统的扩展布局但数据文件里通道的排列顺序并不一定是前端到后端整齐排列的。有的版本按采集设备出厂顺序排列有的版本按额叶、颞叶、顶叶、枕叶分组。如果你后面要画二维拓扑图、做通道相关性分析或者把特征整理成 62 × 频带矩阵输入卷积网络通道顺序就必须和坐标列表严格一致。我的做法是先建立一个独立的通道序号映射channel_names [FP1, FPZ, FP2, ...] # 以官方文档为准 channel_index_map {name: idx for idx, name in enumerate(channel_names)} # 如果你的坐标文件顺序不同做一次重排 feat_matrix de_feature[channel_index_map[AF3], :] # 示例取值这里没有把完整 62 通道名列全因为数据包版本不同顺序有差异照抄网上某个版本有可能踩坑。你需要做的只是确认一件事你的脑电数据第 0 行到底对应哪个电极然后把后续所有通道相关操作都挂在这张映射表上不要硬编码下标。这一步花费的十分钟会在你画拓扑图时全部省回来。3. 预处理原始脑电到干净样本的必经步骤3.1 先滤波还是先分段边界效应决定了顺序预处理的第一步很少有人质疑顺序是先对整段信号滤波还是先切成片段再滤波。我建议先滤波、后分段。原因在于滤波器尤其是 IIR 滤波器在信号起始和结束位置会产生明显的边界暂态如果先切出短片段再滤波每个片段的两端都会出现振铃伪迹等于把噪声放了进去。对整段连续脑电做一次滤波边界暂态只出现在整段数据的两端切分时丢掉头部和尾部一小段就能避开。from scipy import signal import numpy as np fs 200 # SEED 常见采样率是 1000 或 200以实际文件为准 low, high 1.0, 50.0 # 保留 delta 到 gamma避开直流漂移 def bandpass_filter(data, fs200): b, a signal.butter(4, [low, high], btypebandpass, fsfs) return signal.filtfilt(b, a, data, axis1)filtfilt是零相位滤波和lfilter的区别是它把信号正反各过一遍不会产生相位偏移。对情绪识别来说特征提取阶段不希望波形相位变形这里的零相位很重要。滤波器阶数选 4 是为了在频率选择性和数值稳定性之间平衡阶数太高会产生长尾振铃。这里有一个容易被忽略的参数带通上限。如果信号是 1000 Hz 采样率50 Hz 以上还有信息很多人习惯直接 0.5 Hz 到 40 Hz 带通然后发现结果差一截——因为差分熵特征需要用到 gamma 频段而 gamma 频段能量偏高且对情绪变化敏感把它滤掉等于少了一个有效视角。我通常保留到 45 Hz 或 50 Hz如果数据里存在明显工频干扰再加一个 50 Hz 陷波滤波器单独处理而不是简单把带通上限调低。3.2 分段与基线校正一个小细节影响所有特征数值滤波完成后进入分段环节。每个电影片段时长约 4 分钟你可以直接整段处理也可以切成 4 秒窗口。整段处理的优点是忠实还原论文设置缺点是样本量太少——每个被试三轮实验总共 45 个片段类别不平衡时分类器学不充分。滑窗切分能十倍以上扩大样本量代价是相邻窗口高度相关验证时如果不做样本去重会引入乐观偏差。def sliding_windows(x, win_len4.0, step_len2.0, fs200): n_win int(win_len * fs) n_step int(step_len * fs) if n_step 0 or n_win 0: raise ValueError(窗口参数不合法) windows [] for start in range(0, x.shape[1] - n_win 1, n_step): windows.append(x[:, start:start n_win]) return np.stack(windows)这里的核心参数是step_len。选 2 秒会产生 50% 重叠样本量翻倍但相关性增加选 4 秒则完全不重叠样本独立但数量少。我一般用 4 秒窗口、4 秒步长做基准实验等到调参阶段再切成 2 秒步长补充样本。要注意滑窗产生的相邻样本属于同一被试同一片段如果验证集随机划分时这两个相邻窗口一个进训练集一个进测试集就是变相数据泄漏后面章节会专门讲。分段后是基线校正。脑电信号有很强的直流偏置不同通道直流电平差异很大如果直接算差分熵基线漂移会污染频带功率。最常见做法是取每个窗口前 200 毫秒或 500 毫秒作为基线用这段的平均值减去整段信号。SEED 原始数据中电影片段起始位置有一定前导静息段但切分时不一定都会包含所以基线校正依赖的是你滑窗内部的数据而不是原始文件里某个固定位置。3.3 坏导联与伪迹你不检查特征就会替你检查SEED 是离线采集数据质量总体好于在线实验但坏导联依然存在。某一次实验中某个通道因接触不良出现长时间平直信号或出现极端摆动这种情况如果直接进入特征提取分类器可能学会识别“这个通道坏了”而不是“情绪变化了”。def detect_bad_channels(data, mad_thresh6.0): std_vals np.std(data, axis1) median_std np.median(std_vals) mad_std np.median(np.abs(std_vals - median_std)) 1e-6 z np.abs(std_vals - median_std) / (1.4826 * mad_std) return np.where(z mad_thresh)[0]这里用标准差和 MAD 而不是均值和标准差是因为 MAD 对极端值更鲁棒。mad_thresh6.0是经验值实践中一次实验 62 个通道里通常不超过 2 个通道超过该阈值。检测出来后不要直接删掉整行而是把坏通道置零或插值。直接删除会破坏通道的拓扑连续性后续画拓扑图或做空间特征时会少一块。置零的做法简单粗暴但不影响分类器因为特征是按频带算的坏通道全为零后特征稳定且不产生虚假相关。插值则利用周围通道加权重建能保留拓扑结构但实现复杂且可能引入相邻通道信号混叠。我通常选择置零并在实验记录中注明哪些通道被处理过。4. 特征提取与建模DE 特征加 SVM 是最短可落地路径4.1 为什么是差分熵而不是功率谱密度SEED 上的经典特征之一是差分熵。相比直接用功率谱密度分段平均作为特征差分熵对同一频带的能量做了一次对数压缩让特征分布更接近高斯这对依赖距离度量的 SVM 非常友好。另外差分熵本质上是基于高斯分布假设下对不确定性的度量在脑电这种非平稳信号上比均值、方差等简单统计量更能刻画频带活跃程度。DE 的计算公式并不复杂。对于某个频带如果信号在该频带的瞬时幅值近似服从高斯分布则差分熵等于 0.5 乘以 log(2πeσ²)其中 σ² 是该频带内的信号功率。工程实现上不需要先估计概率分布直接计算每个频带功率然后做对数变换即可。def compute_de_bands(data, fs200, freq_bandsNone): if freq_bands is None: freq_bands [(1, 4), (4, 8), (8, 12), (12, 30), (30, 50)] all_feats [] for band in freq_bands: f, psd signal.welch(data, fsfs, npersegfs * 2, noverlapfs // 2) mask (f band[0]) (f band[1]) band_power np.mean(psd[:, mask], axis1) 1e-12 de 0.5 * np.log(2 * np.pi * np.e * band_power) all_feats.append(de) return np.stack(all_feats, axis1)npersegfs * 2表示每个 Welch 段长度为 2 秒对应 0.5 Hz 的频率分辨率这是脑电频带划分下比较合理的取值。太短会导致低频段只有少数频率点估计不稳太长则降低时间分辨率。noverlapfs // 2是 50% 重叠让相邻段的功率谱估计更平滑。这段代码的输出形状是 62 × 5每行是一个通道每列是一个频带。1e-12是为了防止静音通道功率为零导致取对数报错。实际跑完特征后可以打印一下数值范围如果出现大量负数不要惊讶——对数变换后的差分熵取值可正可负分类器并不在乎这一点。4.2 特征向量怎么组织平铺、矩阵还是伪图像DE 特征算完之后面临一个选择是拉平成 62×5310 维向量喂给传统分类器还是保持 62×5 的矩阵结构喂给卷积网络。传统做法是把每个通道每个频带的 DE 值按固定顺序拉平。这里顺序必须固定同一个通道的 5 个频带挨在一起不要先排所有通道的 delta 再排 theta这会破坏特征和被试通道的对应关系。对 SVM 来说特征顺序影响不大但当你回头看某个通道某个频带对分类贡献时混乱的顺序会让你无从下手。代码组织上我习惯做一个函数输入是原始脑电窗口输出是可直接训练的特征矩阵def extract_dataset(windows, fs200): feat_list [] for win in windows: de compute_de_bands(win, fsfs) feat_list.append(de.reshape(-1)) return np.stack(feat_list)这里de.reshape(-1)是按行优先顺序展平即先通道 0 的 5 个频带再通道 1 的 5 个频带。如果想要矩阵结构作为 CNN 输入不要 reshape而是保持 62×5后续可以插值成 64×5 的伪图像。4.3 SVM 参数选择C 和 gamma 不好好调特征白提SVM 在 310 维特征上表现稳定尤其当样本量只有几千的时候不需要深度模型就能达到可观的准确率。但 SVM 对超参数敏感默认参数往往不是最优的。from sklearn.pipeline import make_pipeline from sklearn.preprocessing import StandardScaler from sklearn.svm import SVC from sklearn.model_selection import GridSearchCV clf make_pipeline(StandardScaler(), SVC(kernelrbf, class_weightbalanced)) param_grid { svc__C: [0.1, 1.0, 10.0, 100.0], svc__gamma: [0.001, 0.01, 0.1] } grid GridSearchCV(clf, param_grid, cv5, scoringaccuracy, n_jobs-1)StandardScaler在这里不是可选项。DE 特征虽然做了对数压缩不同频带的数值范围仍然有差异gamma 频带波动通常高于 delta 频带。如果不做标准化SVM 的径向基核会把距离度量主导权交给数值最大的特征其他频带贡献被稀释。C和gamma的组合逻辑是这样C 控制对错误样本的惩罚力度C 越大越容易过拟合训练集中的细微波动gamma 控制 RBF 核的作用半径gamma 越大每个支持向量的影响范围越小决策边界越复杂。SEED 特征的合理区间通常在 C1 到 100、gamma0.01 到 0.1但每个特征提取配置不同直接套用不等于最优。网格搜索是最可靠的方法代价是 4×3×5 折共 60 次训练对于几千样本完全能接受。4.4 深度模型的接入点从 DE 特征到 CNN 或 LSTM如果你不想止步于 SVMSEED 上有两条成熟的深度路径。一条是把 DE 特征重排成 62×5 的通道—频带矩阵当作二维输入送入小规模 CNN。注意这里的“二维”不是图像意义上的空间二维而是通道 × 频带的语义二维CNN 的卷积核更多是在捕捉相邻频带之间的关联。另一条是直接用原始脑电片段送入 LSTM。这条路径看起来更“端到端”但训练难度明显更大。62 通道、每段 4 秒、1000 Hz 采样率意味着每个样本是 62×4000 的矩阵直接展开成序列会让 LSTM 极难收敛显存占用也高。我建议先降采样到 200 Hz再对每个通道独立提取一个低频包络序列这样做能保留时序信息同时减少序列长度。import torch.nn as nn class EEG_LSTM(nn.Module): def __init__(self, n_channels62, hidden64, num_classes3): super().__init__() self.lstm nn.LSTM(input_sizen_channels, hidden_sizehidden, batch_firstTrue) self.fc nn.Linear(hidden, num_classes) def forward(self, x): out, _ self.lstm(x) # x: (B, T, 62) out out[:, -1, :] return self.fc(out)这段代码只展示了一个最小骨架。batch_firstTrue表示输入的 batch 和序列长度维度排在前面注意forward的输入形状必须是(B, T, 62)即每个时刻送所有通道。序列方向是时间而不是通道方向这个顺序颠倒了模型基本学不出东西。我建议第一次跑通项目时走 DE 特征 SVM 路线它能让整个流程在半小时内跑通建立正确的验证框架。等精度和验证方式都稳定后再引入深度模型否则你很难区分是网络结构问题还是数据问题。5. 踩坑与常见问题跑不出“高分”的五个真实原因5.1 随机划分准确率高达 99%数据泄漏让你误以为是模型强大现象用 train_test_split(random_state42) 随机划分训练测试集准确率冲到 95% 以上换成按被试划分直接掉到 70%。原因同一个被试的相邻滑窗高度相似随机划分时这些相似样本同时出现在训练集和测试集分类器实际上在背样本而不是学情绪模式。解决验证时按被试分组保证同一个被试的所有片段只在一侧。5.2 训练精度高、验证崩盘标签和文件排序没对齐现象训练集准确率正常测试集准确率接近随机猜测。原因标签文件名和脑电文件名分别来自两个文件夹按文件名排序后未必一一对应。解决用片段编号作为唯一关联键构建字典映射不要在加载数据后用 zip 直接配对。5.3 scipy.io.loadmat 报错文件版本是 v7.3 而不是旧版 MATLAB 格式现象loadmat 抛 ValueError提示 Unsupported feature。原因新版 MATLAB 默认保存格式是 v7.3本质是 HDF5scipy 不支持。解决改用 h5py 读取读数组后转置一次。注意 h5py 读出来的数据索引顺序是 MATLAB 的反转先检查 shape 再使用不要臆测。5.4 特征提取跑完但全部是 NaN噪声通道与对数碰撞现象compute_de_bands 输出中出现 NaN 或无穷大。原因某通道某频带功率恰好为零np.log(0)直接爆炸或者信号里存在无穷值污染频带估计。解决功率加一个 1e-12 的 epsilon在滤波前用数值检查函数扫描原始数据中的非有限值并替换为局部均值。5.5 62 通道原始信号直接进 LSTM 导致显存或内存溢出现象训练时显存快速占满或者一个 epoch 耗时数小时。原因序列长度太长62 通道 × 4000 时间点LSTM 的时间步是 4000每一步都要处理 62 维输入。解决降采样到 200 Hz 减少序列长度到 800或先提取每个通道每 0.5 秒的均值、方差做成降维表示再送入循环网络。这也是为什么多数 SEED 复现项目优先选 DE 特征而不是原始信号——显存代价完全不在一个量级。6. 把分数再往上提三个值得坚持的验证习惯6.1 用留一被试法替代随机划分SEED 最接近真实应用的评估方法是被试无关评估每次留下一个被试的所有数据作为测试集其余被试作为训练集评估 15 轮取平均。这个协议下分数会比随机划分低 10 到 15 个百分点但更有说服力。很多高分项目的诀窍不是模型更复杂而是验证方式更严格在留一被试协议下依然能拿到好成绩才说明模型确实学会了情绪表征。from sklearn.model_selection import LeaveOneGroupOut from sklearn.model_selection import cross_val_score groups np.repeat(subject_ids, samples_per_subject) # 每个样本一个组标签 scores cross_val_score(clf, X, y, groupsgroups, cvLeaveOneGroupOut(), scoringaccuracy) print(LOSO 平均准确率: %.2f - %.2f % (scores.mean(), scores.std()))LeaveOneGroupOut的关键在于传给groups的是被试编号而不是片段编号否则一个被试出现多个组就会被拆散。这里的语义是“组内不可分”和 StratifiedKFold 的“按类别分层”是两码事写混了会静默产生错误验证结果。6.2 缓存特征让网格搜索跑得更快特征提取通常只做一次代价是几分钟。网格搜索要反复尝试超参数组合每次都重算特征纯属浪费。把 DE 特征保存成 npy 或 npz 文件后续所有实验都从缓存加载。np.savez(de_features.npy, XX, yy, subject_idssubject_ids) # 之后每次实验 data np.load(de_features.npy) X, y data[X], data[y]这里我保存了subject_ids而不是只保存标签就是为了防患于未然——你可能在算完特征后才发现验证协议需要按被试分组如果没有这个数组就得回头重新加载原始数据重跑一遍特征提取白白浪费时间。6.3 画一张混淆矩阵和一个频带贡献分布图准确率只能告诉你数字混淆矩阵能告诉你模型把哪两类情绪搞混。SEED 三分类中常见的混淆是对负性和中性区分困难因为中性情绪往往和低唤醒度的负性情绪在脑电特征上接近。把混淆矩阵打出来对照频带的平均 DE 热图你能快速定位是特征选择问题还是分类器偏差问题。import matplotlib.pyplot as plt from sklearn.metrics import ConfusionMatrixDisplay ConfusionMatrixDisplay.from_predictions(y_true, y_pred, display_labels[neg, neu, pos]) plt.tight_layout() plt.show()配合频带热图的做法是计算所有正性样本和所有负性样本在各频带的平均 DE 差值画成 62 通道 × 5 频带的热图看哪个频带差异最大。通常 gamma 频带在额叶区域的差异最明显这可以成为你报告里最有价值的分析图。这套流程跑下来你会发现“高分”不来自什么神乎其神的模型结构而是来自对数据、特征和验证协议的准确理解。我第一次跑 SEED 时也迷信过把模型换成 LSTM 之后分数自然上去结果在留一被试验证下同样的数据从 92% 掉到 74%才发现问题出在预处理特征和验证方式而不是网络层数。后来每一轮实验我都把被试分组信息当作第一优先级保存把验证方案写在特征提取之前。现在每次拿到一个新数据集我都用同样的流程先跑一个 DE 特征加线性分类器的基线再决定要不要上深度学习。这个方法帮你省掉很多无效尝试希望帮到你。本文还有配套的精品资源点击获取