简介:面向脑机接口与情感计算学习者的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 三次实验与文件命名:同一个被试为什么有三个 .mat
SEED 数据集的采集方式是让被试观看诱发情绪的电影片段,然后记录 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, fs=200): b, a = signal.butter(4, [low, high], btype="bandpass", fs=fs) return signal.filtfilt(b, a, data, axis=1)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_len=4.0, step_len=2.0, fs=200): 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_thresh=6.0): std_vals = np.std(data, axis=1) 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_thresh=6.0是经验值,实践中一次实验 62 个通道里通常不超过 2 个通道超过该阈值。检测出来后不要直接删掉整行,而是把坏通道置零或插值。直接删除会破坏通道的拓扑连续性,后续画拓扑图或做空间特征时会少一块。
置零的做法简单粗暴但不影响分类器,因为特征是按频带算的,坏通道全为零后特征稳定且不产生虚假相关。插值则利用周围通道加权重建,能保留拓扑结构,但实现复杂且可能引入相邻通道信号混叠。我通常选择置零,并在实验记录中注明哪些通道被处理过。
4. 特征提取与建模:DE 特征加 SVM 是最短可落地路径
4.1 为什么是差分熵而不是功率谱密度
SEED 上的经典特征之一是差分熵。相比直接用功率谱密度分段平均作为特征,差分熵对同一频带的能量做了一次对数压缩,让特征分布更接近高斯,这对依赖距离度量的 SVM 非常友好。另外,差分熵本质上是基于高斯分布假设下对不确定性的度量,在脑电这种非平稳信号上比均值、方差等简单统计量更能刻画频带活跃程度。
DE 的计算公式并不复杂。对于某个频带,如果信号在该频带的瞬时幅值近似服从高斯分布,则差分熵等于 0.5 乘以 log(2πeσ²),其中 σ² 是该频带内的信号功率。工程实现上不需要先估计概率分布,直接计算每个频带功率,然后做对数变换即可。
def compute_de_bands(data, fs=200, freq_bands=None): 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, fs=fs, nperseg=fs * 2, noverlap=fs // 2) mask = (f >= band[0]) & (f < band[1]) band_power = np.mean(psd[:, mask], axis=1) + 1e-12 de = 0.5 * np.log(2 * np.pi * np.e * band_power) all_feats.append(de) return np.stack(all_feats, axis=1)nperseg=fs * 2表示每个 Welch 段长度为 2 秒,对应 0.5 Hz 的频率分辨率,这是脑电频带划分下比较合理的取值。太短会导致低频段只有少数频率点,估计不稳;太长则降低时间分辨率。noverlap=fs // 2是 50% 重叠,让相邻段的功率谱估计更平滑。
这段代码的输出形状是 62 × 5,每行是一个通道,每列是一个频带。+1e-12是为了防止静音通道功率为零导致取对数报错。实际跑完特征后可以打印一下数值范围,如果出现大量负数不要惊讶——对数变换后的差分熵取值可正可负,分类器并不在乎这一点。
4.2 特征向量怎么组织:平铺、矩阵还是伪图像
DE 特征算完之后面临一个选择:是拉平成 62×5=310 维向量喂给传统分类器,还是保持 62×5 的矩阵结构喂给卷积网络。
传统做法是把每个通道每个频带的 DE 值按固定顺序拉平。这里顺序必须固定,同一个通道的 5 个频带挨在一起,不要先排所有通道的 delta 再排 theta,这会破坏特征和被试通道的对应关系。对 SVM 来说,特征顺序影响不大,但当你回头看某个通道某个频带对分类贡献时,混乱的顺序会让你无从下手。
代码组织上我习惯做一个函数,输入是原始脑电窗口,输出是可直接训练的特征矩阵:
def extract_dataset(windows, fs=200): feat_list = [] for win in windows: de = compute_de_bands(win, fs=fs) 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(kernel="rbf", class_weight="balanced")) 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, cv=5, scoring="accuracy", n_jobs=-1)StandardScaler在这里不是可选项。DE 特征虽然做了对数压缩,不同频带的数值范围仍然有差异,gamma 频带波动通常高于 delta 频带。如果不做标准化,SVM 的径向基核会把距离度量主导权交给数值最大的特征,其他频带贡献被稀释。
C和gamma的组合逻辑是这样:C 控制对错误样本的惩罚力度,C 越大越容易过拟合训练集中的细微波动;gamma 控制 RBF 核的作用半径,gamma 越大每个支持向量的影响范围越小,决策边界越复杂。SEED 特征的合理区间通常在 C=1 到 100、gamma=0.01 到 0.1,但每个特征提取配置不同,直接套用不等于最优。网格搜索是最可靠的方法,代价是 4×3×5 折共 60 次训练,对于几千样本完全能接受。
4.4 深度模型的接入点:从 DE 特征到 CNN 或 LSTM
如果你不想止步于 SVM,SEED 上有两条成熟的深度路径。一条是把 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_channels=62, hidden=64, num_classes=3): super().__init__() self.lstm = nn.LSTM(input_size=n_channels, hidden_size=hidden, batch_first=True) 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_first=True表示输入的 batch 和序列长度维度排在前面,注意forward的输入形状必须是(B, T, 62),即每个时刻送所有通道。序列方向是时间,而不是通道方向,这个顺序颠倒了模型基本学不出东西。
我建议第一次跑通项目时走 DE 特征 + SVM 路线,它能让整个流程在半小时内跑通,建立正确的验证框架。等精度和验证方式都稳定后再引入深度模型,否则你很难区分是网络结构问题还是数据问题。
5. 踩坑与常见问题:跑不出“高分”的五个真实原因
5.1 随机划分准确率高达 99%:数据泄漏让你误以为是模型强大
现象:用 train_test_split(random_state=42) 随机划分训练测试集,准确率冲到 95% 以上,换成按被试划分直接掉到 70%。原因:同一个被试的相邻滑窗高度相似,随机划分时这些相似样本同时出现在训练集和测试集,分类器实际上在背样本而不是学情绪模式。解决:验证时按被试分组,保证同一个被试的所有片段只在一侧。
5.2 训练精度高、验证崩盘:标签和文件排序没对齐
现象:训练集准确率正常,测试集准确率接近随机猜测。原因:标签文件名和脑电文件名分别来自两个文件夹,按文件名排序后未必一一对应。解决:用片段编号作为唯一关联键,构建字典映射,不要在加载数据后用 zip 直接配对。
5.3 scipy.io.loadmat 报错:文件版本是 v7.3 而不是旧版 MATLAB 格式
现象:loadmat 抛 ValueError,提示 Unsupported feature。原因:新版 MATLAB 默认保存格式是 v7.3,本质是 HDF5,scipy 不支持。解决:改用 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, groups=groups, cv=LeaveOneGroupOut(), scoring="accuracy") print("LOSO 平均准确率: %.2f +- %.2f" % (scores.mean(), scores.std()))LeaveOneGroupOut的关键在于传给groups的是被试编号而不是片段编号,否则一个被试出现多个组就会被拆散。这里的语义是“组内不可分”,和 StratifiedKFold 的“按类别分层”是两码事,写混了会静默产生错误验证结果。
6.2 缓存特征,让网格搜索跑得更快
特征提取通常只做一次,代价是几分钟。网格搜索要反复尝试超参数组合,每次都重算特征纯属浪费。把 DE 特征保存成 npy 或 npz 文件,后续所有实验都从缓存加载。
np.savez("de_features.npy", X=X, y=y, subject_ids=subject_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 特征加线性分类器的基线,再决定要不要上深度学习。这个方法帮你省掉很多无效尝试,希望帮到你。
本文还有配套的精品资源,点击获取