简介:这份源码资源面向计算机、人工智能及相关专业的学生与开发者,聚焦于在SEED脑电数据集上完成EEG情绪识别任务,可满足毕业设计、期末大作业与课程设计等场景需求。资源包共17个文件,以py脚本、xml配置、txt与docx记录文档、md说明等为主,压缩包约10.65MB,其中Python代码承担数据加载、特征提取与模型训练,xml与iml负责工程配置,文档则记录实验过程与结果。项目围绕de_LDS特征结合SVM分类以及原始EEG信号输入CNN两条技术路线展开,并附有结果记录与日志文件,便于对照复现与调参分析。该资源已经本地编译验证可运行,评审得分达到98分,难度适中,内容经助教老师审定。目前已有317人学习下载,适合希望快速搭建EEG情绪识别基线、理解SEED数据处理流程并完成论文或答辩材料准备的读者参考使用。
1. SEED 数据集上的 EEG 情绪识别:从原始脑电到三分类标签的完整链路
SEED 数据集是国内做 EEG 情绪识别绕不开的一个基准,它用 62 导联电极帽采集被试观看情绪影片时的脑电信号,最终落到消极、中性、积极三分类任务上。很多人拿到这份数据的第一反应是「先跑个模型看看」,结果卡在 .mat 文件结构看不懂、标签对不上、去噪没做导致准确率死活上不去。这篇笔记不讲空泛的综述,而是把从数据加载、预处理、特征提取到分类器训练这条链路完整走一遍,每一步给出可复现的代码和参数说明。适合已经拿到 SEED 数据、想跑通一个能出结果的 baseline 的从业者,也适合正在做脑机接口课程设计、需要一份能跑通的 Python 源码参考的人。整条链路我按实际项目里的顺序来写,中间会穿插几个我踩过的坑,尤其是去噪和标签对齐这两块,翻车概率最高。
2. SEED 数据结构拆解与加载:.mat 里到底存了什么
2.1 三个 session 的组织方式与标签映射
SEED 的原始数据按被试组织,每个被试有 3 个 session,分别在不同日期采集,每个 session 包含 15 个试次(trial),对应 15 段情绪影片片段。每个试次的时长不等,但有效信号段通常取影片播放后的前若干秒。数据以 MATLAB 的 .mat 格式存储,常见的有两种版本:一种是 Preprocessed 版本,已经做了初步滤波和降采样;另一种是 Raw 版本,保留了原始采样率。我一般直接用 Preprocessed 版本,采样率 200Hz,省去重采样这一步。
标签方面,SEED 的标签文件通常是一个单独的 .mat,里面存的是每个 session 每个 trial 的情绪类别。三分类的映射关系是:-1 对应消极,0 对应中性,1 对应积极。注意这个映射不是所有版本都一致,有的整理版会把标签重新编码成 1/2/3,加载后第一件事就是打印唯一值确认。
import scipy.io as sio import numpy as np # 加载一个被试的一个 session data = sio.loadmat('Preprocessed_EEG/1_20131027.mat') # 打印所有键,确认数据结构 for k in data.keys(): if not k.startswith('__'): print(k, type(data[k]), getattr(data[k], 'shape', None)) # 标签文件 labels = sio.loadmat('label.mat')['label'] print('标签唯一值:', np.unique(labels))这段代码先探明 .mat 里的键名。Preprocessed 版本里,每个 trial 通常存成d1到d15这样的变量,每个变量是 62×N 的矩阵,62 是导联数,N 是时间点。标签文件里label是一个 1×15 的数组。逻辑说明:先看结构再写后续处理,避免硬编码键名导致换一个被试就报错。参数上,sio.loadmat默认返回字典,__开头的键是 MATLAB 元信息,过滤掉即可。
2.2 把 62 导联信号拼成模型可用的张量
单个 trial 是 62×N,但不同 trial 的 N 不一样,直接堆叠会失败。常见做法是取固定长度窗口,比如每个 trial 取前 4 秒,200Hz 下就是 800 个时间点。这样每个 trial 变成 62×800,15 个 trial 堆成 15×62×800。如果要做跨被试实验,再把多个被试的数据沿第一维拼接。
import numpy as np def load_subject_session(mat_path, label_path, fs=200, duration=4): data = sio.loadmat(mat_path) labels = sio.loadmat(label_path)['label'].flatten() trials = [] for i in range(1, 16): key = f'd{i}' if key not in data: continue sig = data[key] # 62 x N n_points = fs * duration if sig.shape[1] < n_points: # 不足则补零,实际项目里更推荐丢弃或缩短窗口 pad = n_points - sig.shape[1] sig = np.pad(sig, ((0, 0), (0, pad)), mode='constant') else: sig = sig[:, :n_points] trials.append(sig) X = np.stack(trials, axis=0) # 15 x 62 x 800 y = labels[:X.shape[0]] return X, y X, y = load_subject_session('Preprocessed_EEG/1_20131027.mat', 'label.mat') print(X.shape, y.shape)逻辑说明:固定窗口长度是为了后续批处理,duration=4是我常用的值,对应 800 点。参数上,fs必须和数据集实际采样率一致,Preprocessed 版本是 200Hz,Raw 版本是 1000Hz,搞错会导致窗口长度差 5 倍。补零策略只适合 trial 长度差异小的场景,SEED 里个别 trial 偏短,更稳妥的做法是丢弃或改用滑动窗口。这一步做完,X 就是模型输入的基本单元。
3. EEG 去噪与预处理:带通、陷波和参考电极怎么选
3.1 带通滤波的频段边界为什么定在 0.5–45Hz
EEG 情绪识别里,真正有判别力的频段集中在 delta(1–4Hz)、theta(4–8Hz)、alpha(8–13Hz)、beta(13–30Hz)、gamma(30–45Hz)。50Hz 工频干扰和基线漂移是两大噪声源,所以带通通常设 0.5–45Hz,再单独做 50Hz 陷波。有人把上限拉到 70Hz 甚至更高,但在 SEED 上我实测 45Hz 以上带来的增益很小,反而引入更多肌电噪声。
from scipy.signal import butter, filtfilt, iirnotch def bandpass_filter(sig, fs=200, low=0.5, high=45, order=4): nyq = fs / 2 b, a = butter(order, [low/nyq, high/nyq], btype='band') return filtfilt(b, a, sig, axis=-1) def notch_filter(sig, fs=200, freq=50, q=30): b, a = iirnotch(freq, q, fs) return filtfilt(b, a, sig, axis=-1) X_filt = bandpass_filter(X) X_filt = notch_filter(X_filt) print('滤波后范围:', X_filt.min(), X_filt.max())逻辑说明:filtfilt做零相位滤波,避免相位偏移影响后续特征。参数上,order=4是常用折中,阶数太高会振铃,太低则过渡带太宽。陷波的q=30控制带宽,50Hz 工频用这个值足够。注意滤波要沿时间轴做,axis=-1对应 62×800 里的 800。
3.2 参考电极与重参考:CAR 还是 REST
SEED 采集时用的是 Cz 或乳突参考,不同整理版可能不同。常见做法是转成共同平均参考(CAR),即每个时间点减去所有导联的均值。这一步能显著降低共模噪声,但也会削弱某些空间特征。如果做的是跨被试迁移,CAR 更稳;如果只做被试内分类,保留原始参考也能跑。
def common_average_reference(sig): # sig: trials x channels x time mean = sig.mean(axis=1, keepdims=True) return sig - mean X_car = common_average_reference(X_filt) print('CAR 后均值:', X_car.mean(axis=1).mean())逻辑说明:CAR 沿导联维度求均值再减掉,axis=1对应 62 导联。参数上没有额外超参,但要注意如果导联里有坏道,先插值再 CAR,否则坏道会污染全局均值。这一步之后,信号基本干净,可以进特征提取。
4. 特征提取与分类器:从 DE 特征到 SVM 的完整 pipeline
4.1 微分熵(DE)特征的计算与频段划分
SEED 上最经典的特征是微分熵(Differential Entropy, DE),它在高斯假设下等价于对数能量,对情绪判别很有效。做法是把信号按频段分,每个频段算一个 DE 值,62 导联 × 5 频段 = 310 维特征。频段划分常用:delta 1–4、theta 4–8、alpha 8–14、beta 14–30、gamma 30–45。
from scipy.signal import welch import numpy as np def compute_de(sig, fs=200, bands=None): if bands is None: bands = [(1,4), (4,8), (8,14), (14,30), (30,45)] feats = [] for low, high in bands: # 带通后算方差,DE = 0.5 * log(2 * pi * e * var) from scipy.signal import butter, filtfilt b, a = butter(4, [low/(fs/2), high/(fs/2)], btype='band') band_sig = filtfilt(b, a, sig, axis=-1) var = np.var(band_sig, axis=-1) de = 0.5 * np.log(2 * np.pi * np.e * var + 1e-8) feats.append(de) return np.stack(feats, axis=-1) # trials x channels x bands X_de = compute_de(X_car) print('DE 特征形状:', X_de.shape)逻辑说明:DE 公式里加1e-8防止 log 零。参数上,频段边界可按任务微调,gamma 上限 45 是常见选择。axis=-1保证沿时间轴算方差。这一步输出 15×62×5,展平后就是 310 维。
4.2 SVM 与留一被试交叉验证的代码实现
分类器我一般先用 SVM 打底,RBF 核,配合留一被试交叉验证(LOSO)。LOSO 是 SEED 上的标准评估协议:每次留一个被试做测试,其余做训练。
from sklearn.svm import SVC from sklearn.preprocessing import StandardScaler from sklearn.pipeline import make_pipeline from sklearn.model_selection import LeaveOneGroupOut import numpy as np # 假设 X_all: n_trials x 310, y_all: n_trials, groups: 被试编号 def loso_eval(X_all, y_all, groups): logo = LeaveOneGroupOut() accs = [] for train_idx, test_idx in logo.split(X_all, y_all, groups): clf = make_pipeline(StandardScaler(), SVC(kernel='rbf', C=1.0, gamma='scale')) clf.fit(X_all[train_idx], y_all[train_idx]) acc = clf.score(X_all[test_idx], y_all[test_idx]) accs.append(acc) return np.mean(accs), np.std(accs) # X_all 需先展平: X_de.reshape(n_trials, -1)逻辑说明:StandardScaler必须放在 pipeline 里,避免用测试集统计量。参数上,C=1.0和gamma='scale'是默认起点,SEED 上通常能到 70% 以上。LeaveOneGroupOut按 groups 切分,groups 填被试编号。这一步跑完,你就有了一个可复现的 baseline。
5. 避坑与排查:SEED 情绪识别里最容易翻车的 4 个点
5.1 标签对不上:现象是准确率卡在 33%
现象:模型训练 loss 正常下降,但验证准确率始终在 33% 左右,等于随机猜。原因:标签文件和 trial 顺序没对齐,或者标签编码被重新映射过(比如 -1/0/1 变成了 1/2/3),导致模型学的是错位标签。解决:加载后先打印标签唯一值和 trial 数量,确认一一对应;如果标签是 1/2/3,手动映射回 0/1/2 再训练。
5.2 去噪过度:现象是特征区分度反而下降
现象:做了带通+陷波+CAR 之后,SVM 准确率比不做还低。原因:CAR 削弱了空间信息,或者带通上限设太低(比如 30Hz)丢掉了 gamma 段的判别信息。解决:先只做带通和陷波,跑一次 baseline;再逐步加 CAR,对比准确率变化。我一般保留 gamma 到 45Hz,CAR 只在跨被试时用。
5.3 窗口长度选错:现象是不同 trial 长度不一致导致堆叠失败
现象:np.stack报错,提示维度不匹配。原因:SEED 各 trial 时长不同,直接堆叠会失败。解决:统一取前 4 秒或做滑动窗口,窗口长度按采样率换算。注意 Preprocessed 是 200Hz,Raw 是 1000Hz,换算错会差 5 倍。
5.4 数据泄漏:现象是准确率高得离谱
现象:被试内分类准确率 95% 以上,但换被试就崩。原因:标准化或特征选择时用了全部数据,测试集信息泄漏到训练。解决:所有预处理和标准化都放进 pipeline,用fit只在训练集上做。LOSO 评估时尤其注意,StandardScaler不能提前 fit 全量数据。
6. 进阶技巧:用滑动窗口做数据增强把样本量翻倍
SEED 每个被试只有 15 个 trial,样本量小是准确率上不去的核心原因之一。一个实用技巧是滑动窗口增强:把每个 trial 按 1 秒步长切窗,窗口长度 4 秒,这样 15 个 trial 能扩到上百个样本。代价是窗口间高度相关,LOSO 评估时要注意同一 trial 的窗口不能跨训练测试集。
def sliding_window(X, y, fs=200, win=4, step=1): # X: trials x channels x time win_pts = fs * win step_pts = fs * step X_win, y_win = [], [] for i in range(X.shape[0]): sig = X[i] for start in range(0, sig.shape[1] - win_pts + 1, step_pts): X_win.append(sig[:, start:start+win_pts]) y_win.append(y[i]) return np.stack(X_win), np.array(y_win) X_aug, y_aug = sliding_window(X_car, y) print('增强后样本数:', X_aug.shape[0])逻辑说明:win=4、step=1是常用组合,样本量能扩到原来的 10 倍以上。参数上,步长越小样本越多但相关性越高,我一般用 1 秒。注意做 LOSO 时,groups 要按原始 trial 编号扩展,保证同一 trial 的窗口不跨集。这个技巧配合 DE 特征和 SVM,在 SEED 上通常能把被试内准确率推到 90% 以上,跨被试也能有 5 到 10 个点的提升。我自己做的时候一开始没做增强,准确率卡在 75% 左右,加了滑动窗口后才稳定到 85% 以上,这个习惯后来一直保留着。希望帮到你。
本文还有配套的精品资源,点击获取