ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

DEAP脑电情绪二分类实践:从信号预处理到SVM建模避坑指南

DEAP脑电情绪二分类实践:从信号预处理到SVM建模避坑指南 简介基于DEAP脑电数据集的情绪二分类算法面向脑机接口与机器学习入门者提供了一套从原始脑电信号到情绪类别判别的完整代码流程涵盖快速傅里叶变换FFT频域处理、特征提取、数据预处理和分类模型调用等关键环节。压缩包共7个文件以4个Python脚本为核心分别对应FFT变换、特征提取、脑电绘图和流程整合另附说明文档与附赠资源包整体仅8KB轻量易部署目前已有134人学习适合作为课程设计或科研入门参考。代码选用决策树、SVM和KNN三种经典分类器均直接调用库实现无需复杂调参即可复现二分类结果预处理环节包含滤波、去伪迹与归一化等步骤有助于提升分类可靠性用户通过学习该项目可掌握频域分析和传统机器学习在脑电数据上的应用并以此作为后续引入深度模型的基线对比。1. 基于DEAP脑电数据集的脑电情绪二分类先别急着上深度学习第一次跑DEAP数据集的脑电情绪二分类我把32通道原始信号直接丢给一维卷积网络调了半天参准确率停在61%。同等特征下一个线性SVM却跑到了79%。那次的教训很直接基于DEAP脑电数据集的脑电情绪二分类算法重点从来不是模型层数而是你对信号做了什么。DEAP记录了32名被试观看情绪视频时的脑电与外围生理信号每个试次是一分钟情感诱发二分类任务通常把标签切成valence高/低或arousal高/低再用脑电特征判断当前情绪属于哪一类。这篇文章按我踩过的坑从数据加载讲到交叉验证和特征评估适合正在做情绪识别、脑机接口课题的工程师或学生也适合想快速验证某个特征是否有效的算法同学。2. 先把DEAP数据集拆开数据形态、标签对齐与baseline处理2.1 用scipy.io读取mat文件40通道、8064采样点怎么理解DEAP数据集的核心是32个mat文件文件名对应不同被试编号。每个文件里有两个关键字段data和labels。先用一行代码把结构打出来比什么都直观import numpy as np from scipy.io import loadmat seg loadmat(s01.mat) print(seg[data].shape) # (40, 40, 8064) print(seg[labels].shape) # (40, 4)data的第一个维度是40个试次第二个维度是40个通道第三个维度是8064个采样点。8064来自128Hz采样率乘以63秒——每个试次实际是63秒而不是60秒。前3秒384个采样点是被试休息状态的baseline后60秒是视频刺激段的脑电信号。labels的40行和试次一一对应4列分别是valence、arousal、dominance、liking的评分范围1到9。读取后要立刻做一件事把40通道里的后8个外周通道拿掉只保留前32个EEG通道。DEAP采集时同时记录了眼电、肌电、呼吸等外周三通道如果混进后续特征计算模型会先学到眨眼和心率这些非脑电信息。常见做法是一开始就切片eeg seg[data][:, :32, :] # 只取前32个脑电通道 print(eeg.shape) # (40, 32, 8064)单个mat文件读入后占的内存不小32个文件全部load进内存会吃掉好几个GB。如果只是验证特征方案按被试逐个读取、提取特征后释放即可不必一次性全载入。我习惯把每个被试的特征矩阵保存为npy文件后面只加载特征不再碰原始波形。提示DEAP官网还提供提取好特征的feature版本。复现示例论文可以用但如果你想自定义频段、自定义baseline窗口或加新特征建议直接用原始mat数据feature版本是为某些基线实验定制的自由度不够。2.2 扣baseline的两种做法与一个顺序坑DEAP每个试次的前3秒是没有情绪刺激的休息状态。如果不把这部分基线水平从刺激段里减掉模型学到的可能不是情绪诱发效果而是这个人静息状态下脑电长什么样。个体基线差异在跨被试场景下尤其致命——同一个人的静息alpha功率可能是另一个人的两倍模型一旦抓住这个差异换个被试就失效。最常见的做法是逐通道减去baseline段的均值SAMPLE_RATE 128 BASELINE_SAMPLES 3 * SAMPLE_RATE # 384 def remove_baseline_mean(trial): # trial shape: (32, 8064) baseline trial[:, :BASELINE_SAMPLES].mean(axis1, keepdimsTrue) return trial - baseline clean np.array([remove_baseline_mean(t) for t in eeg])参数说明keepdimsTrue保留通道维度否则mean返回形状(32,)无法广播。算术均值对极端值敏感如果某个通道前3秒有明显漂移可以改用np.median(trial[:, :BASELINE_SAMPLES], axis1, keepdimsTrue)。中位数对眨眼和突发噪声更鲁棒代价是略微增加计算量。真正容易踩的坑在顺序先滤波还是先扣baseline。我的经验是先做带通滤波再扣baseline。原因是滤波会消除部分直流偏移和超低频漂移让baseline均值更接近真实的静息水平如果先扣baseline滤波时信号边缘会产生振铃反而引入新的基线偏差。顺序一旦固定后面所有实验都保持一致不要在换特征时就换顺序。2.3 标签切分valence/arousal选哪个阈值怎么定DEAP的标签是1到9的整数评分情绪二分类要先把连续评分切成两类。很多人默认用5做阈值因为5是量表中间值但实际数据里评分分布并不以5为对称中心。有的被试给出的valence很少超过6有的被试很少低于4全局阈值会直接造成类别不平衡。我建议先看分布再切valence seg[labels][:, 0] print(median:, np.median(valence)) print(bincount:, np.bincount(valence))二分类的默认策略是用训练集的中位数作为切分阈值大于中位数归为正类小于等于归为负类。中位数切分保证两类数量基本均衡也避免了跨被试评分尺度不一致的问题。如果你执意用5至少先确认正负样本比例别等分类器全预测多数类时才发现问题。valence和arousal我建议分开做。valence愉悦度的脑电区分度通常比arousal更稳定尤其是额叶alpha不对称性这个经典特征arousal更依赖心率、皮电等外周信号单靠EEG做二分类难度更高。如果你的题目没有明确指定维度从valence入手最容易跑出可信结果。至于用被试内的数据切训练测试还是换被试去预测这个选择直接影响结论的解读——前者验证特征区分度后者验证算法泛化能力后文会专门展开。3. 信号预处理与特征提取从原始波形到分类器能吃的特征3.1 预处理管线陷波、带通、坏通道检查扣完baseline之后原始信号还有三类污染50Hz工频干扰、高频肌电伪迹、低频漂移。标准做法是陷波加带通滤波我用filtfilt而不是sosfilt因为零相位滤波不会引入群延迟对后续时间窗对齐更友好from scipy.signal import butter, filtfilt, iirnotch def preprocess_epoch(epoch, sfreq128, notch_freq50): # epoch shape: (32, 8064) b_notch, a_notch iirnotch(notch_freq, Q30, fssfreq) x filtfilt(b_notch, a_notch, epoch, axis-1) b_band, a_band butter(4, [4, 45], btypebandpass, fssfreq) x filtfilt(b_band, a_band, x, axis-1) return x clean_data np.array([preprocess_epoch(t) for t in clean])参数说明iirnotch的Q值控制陷波带宽Q越大带宽越窄对正常信号损伤越小但若工频频率有偏移则滤不干净。大多数地区电网是50Hz少数是60Hz拿不准时先对信号做FFT找峰值再设置。butter的阶数取4阶数过高会让边缘振荡明显filtfilt是零相位双向滤波等效阶数翻倍到8边缘处理比普通filter好得多。带通范围4-45Hz是DEAP论文里最常见的区间下限滤掉缓慢基线漂移上限滤掉肌电主导的高频噪声。预处理完成后一定要做一次通道检查。我的习惯是计算每个通道的方差打印出来扫一眼某个通道的方差比其他通道大两个数量级通常是电极接触不良或运动伪迹某个通道方差接近0说明信号断线。这些坏通道如果在特征阶段不剔除会让标准化后的特征被单个通道主导模型权重全堆在坏道上。3.2 时域特征统计量与Hjorth三参数时域特征直接从时间序列上计算统计量优点是计算快、物理意义明确缺点是对baseline处理质量极其敏感。如果第2章没扣干净时域特征的均值项基本等于个体基线模型学的不是情绪而是人。我常用的组合是每通道的均值、标准差、均方根加上Hjorth三参数。Hjorth的activity就是方差mobility反映信号斜率变化的快慢complexity描述波形的不规则程度def hjorth(x): x np.asarray(x, dtypenp.float64) dx np.diff(x) ddx np.diff(dx) activity np.var(x) mobility np.sqrt(np.var(dx) / (np.var(x) 1e-12)) complexity np.sqrt(np.var(ddx) / (np.var(dx) 1e-12)) / (mobility 1e-12) return np.array([activity, mobility, complexity]) def time_features(epoch): feats [] for ch in epoch: rms np.sqrt(np.mean(ch ** 2)) feats.append([np.mean(ch), np.std(ch), rms]) feats.append(hjorth(ch)) return np.concatenate(feats) f_temporal np.array([time_features(e) for e in clean_data]) print(f_temporal.shape) # (40, 32 * 6) (40, 192)代码里加1e-12是防止方差接近0的通道除零。一旦某个通道断线导致方差为0mobility和complexity会算出极端值标准化后依然污染模型。建议在特征提取前就把异常通道剔除而不是靠模型自己扛。时域特征的维度是每通道6维32通道共192维。3.3 频域特征频段功率与微分熵的取舍脑电情绪识别最经典的特征是各频段的功率谱密度尤其是theta、alpha、beta三个频段。用Welch方法估计PSD再按频段积分是效率最高也最容易复现的方案from scipy.signal import welch BANDS { delta: (1, 4), theta: (4, 8), alpha: (8, 13), beta: (13, 30), gamma: (30, 45), } def band_power_features(epoch, sfreq128): feats [] for ch in epoch: freqs, psd welch(ch, fssfreq, nperseg256, noverlap128, windowhamming) for fmin, fmax in BANDS.values(): mask (freqs fmin) (freqs fmax) feats.append(np.trapezoid(psd[mask], freqs[mask])) return np.array(feats) f_band np.array([band_power_features(e) for e in clean_data]) print(f_band.shape) # (40, 160)参数说明nperseg256在128Hz采样率下对应2秒窗频率分辨率0.5Hz足以分清alpha8-13Hz和theta4-8Hz的边界。如果窗口缩短到128点分辨率降到1Hz8Hz处的alpha与7.5Hz的theta边界会混叠特征区分度明显下降。noverlap128表示50%重叠窗数翻倍后PSD估计方差更小。低频频段比如delta只有1-4Hz在0.5Hz分辨率下只有6个频率点积分误差偏大可以考虑对delta单独用更长窗代价是特征提取耗时增加。微分熵DE在许多情绪分类论文里几乎是标配。它的本质是假设信号在频带内近似高斯分布用对数方差估计def de_features(epoch, sfreq128): feats [] for ch in epoch: for fmin, fmax in BANDS.values(): b, a butter(4, [fmin, fmax], btypebandpass, fssfreq) y filtfilt(b, a, ch, axis-1) feats.append(0.5 * np.log(2.0 * np.pi * np.e * np.var(y))) return np.array(feats) f_de np.array([de_features(e) for e in clean_data]) print(f_de.shape) # (40, 160)注意微分熵和band power高度相关二者同时加入会让特征冗余。我的做法是band power做主体特征DE只保留gamma频段因为gamma段的传统功率估计方差大DE的log压缩能压低极端值两者叠加才有增量信息。特征不是越多越好堆到五六百维后SVM的RBF核训练变慢随机森林会抓到大量重复分支。3.4 特征拼接与切窗策略先跑整体还是先切窗上面三个小节对每个试次得到一组特征向量时域192维、频域160维、DE的gamma段32维加起来384维。此时有两个路线把整个60秒算成一个特征向量每试次一个样本或者把试次切成短窗每窗提取特征得到一组时间序列特征。我建议先跑整体。全试次特征的特点是样本量小32被试×40试次1280条、训练快、能快速判断特征是否有效。整体路线跑通后再考虑切窗。切窗的收益是样本量增加模型能捕捉情绪随时间变化的动态代价是同一试次的相邻窗口高度相关交叉验证时如果不按试次分组会造成严重的样本泄漏。如果决定切窗常见参数是窗长4秒、步长2秒def sliding_windows(epoch, win_len4, step2, sfreq128): win_len_samples int(win_len * sfreq) step_samples int(step * sfreq) starts range(0, epoch.shape[1] - win_len_samples 1, step_samples) return np.stack([epoch[:, s:s win_len_samples] for s in starts]) # clean_data 已去掉baseline只剩60秒 windowed sliding_windows(clean_data[0]) # (29, 32, 512)这里60秒数据去掉384点baseline后剩7680点窗长512点、步长256点总共29个窗口。切窗后的窗口特征会被拼接成三维数组试次数窗口数特征维度后续建模必须把试次编号记牢任何随机划分都要以试次为单位。4. 特征标准化与二分类建模参数、对比、评估协议4.1 标准化的泄漏坑fold内fit是最低要求之前做过一个模拟项目把所有样本合在一起做StandardScaler再跑五折交叉验证准确率87%。改成每折只fit训练集后准确率掉到74%。这个落差不是分类器变弱了而是之前的87%偷看了测试集的均值和方差属于典型的数据泄漏。正确的标准化方式是每个fold单独fitfrom sklearn.preprocessing import StandardScaler from sklearn.model_selection import StratifiedKFold from sklearn.svm import SVC from sklearn.metrics import accuracy_score, roc_auc_score X np.hstack([f_temporal, f_band, f_de]) y (seg[labels][:, 0] np.median(seg[labels][:, 0])).astype(int) skf StratifiedKFold(n_splits5, shuffleTrue, random_state42) accs, aucs [], [] for tr_idx, te_idx in skf.split(X, y): scaler StandardScaler().fit(X[tr_idx]) X_tr scaler.transform(X[tr_idx]) X_te scaler.transform(X[te_idx]) clf SVC(kernelrbf, C1.0, gammascale, probabilityTrue, random_state42) clf.fit(X_tr, y[tr_idx]) accs.append(accuracy_score(y[te_idx], clf.predict(X_te))) aucs.append(roc_auc_score(y[te_idx], clf.predict_proba(X_te)[:, 1])) print(f5-CV accuracy: {np.mean(accs):.3f}, AUC: {np.mean(aucs):.3f})参数说明gammascale按1除以特征数量乘以方差自动计算gamma比固定gamma值更适合高维特征。C1.0是RBF-SVM的稳妥起点后续可搜索[0.1, 1, 10]。probabilityTrue启用Platt缩放计算概率才能算AUC只看准确率时可以关掉提升速度。random_state固定42是为了复现换随机种子结果若有大幅波动说明样本量或特征稳定性有问题。4.2 分类器对比SVM、随机森林与深度学习怎么选RBF-SVM是DEAP二分类最稳的默认选择尤其在特征维度300维附近、样本量1280时SVM对高维稀疏结构敏感但不容易过拟合。随机森林不需要标准化还自带特征重要性方便做降维分析但精度通常比SVM低几个点。深度学习在这类任务上不是首选除非你做了切窗并引入时间维度。分类器标准化要求特征重要性训练速度过拟合风险适用阶段RBF-SVM需要无快低全试次特征主力模型线性SVM需要系数可解释最快低特征筛选基线随机森林不需要有中中快速验证特征有效性LSTM/CNN建议难以解释慢高切窗时序建模如果RBF-SVM和随机森林准确率都低于65%先别急着换深度学习。DEAP全试次只有1280个样本深度模型在这个规模上几乎没有优势。等全试次特征跑到75%以上再考虑切窗后用LSTM建模时序信息。随机森林的典型用法是看feature_importances_找出排名前100的特征重新训练from sklearn.ensemble import RandomForestClassifier rf RandomForestClassifier(n_estimators200, max_depth10, min_samples_leaf2, random_state42, n_jobs-1) rf.fit(X_tr, y[tr_idx]) sorted_idx np.argsort(rf.feature_importances_)[::-1] top100 sorted_idx[:100]max_depth限制在10左右相当于强正则化防止1280条样本上的过拟合。min_samples_leaf2让叶子节点至少两个样本对噪声更鲁棒。如果取前100维特征后AUC没有明显下降说明原始特征里有大量冗余可以放心压缩。4.3 评估协议五折交叉验证与留一被试验证五折交叉验证是试次级划分训练集和测试集可能来自同一个人这属于被试内评估。它回答的问题是如果知道一个人的标签先验能否用他过去的试次预测未来的试次。这个设置能验证特征是否有效但不能说明算法能否用于陌生人。更严苛的是留一被试交叉验证LOSO每次留出一个被试的全部40个试次做测试其余31个被试做训练from sklearn.model_selection import LeaveOneGroupOut groups np.repeat(np.arange(32), 40) # 每个被试40个试次 logo LeaveOneGroupOut() accs_loso [] for tr_idx, te_idx in logo.split(X, y, groupsgroups): scaler StandardScaler().fit(X[tr_idx]) clf SVC(kernelrbf, C1.0, gammascale) clf.fit(scaler.transform(X[tr_idx]), y[tr_idx]) acc accuracy_score(y[te_idx], clf.predict(scaler.transform(X[te_idx]))) accs_loso.append(acc) print(fLOSO accuracy: {np.mean(accs_loso):.3f})groups数组长度必须等于X行数这里32个被试各40个试次共1280条。pass内fit标准化只用了训练被试测试被试的统计量完全没有参与。LOSO结果通常比五折低10到15个百分点这是正常的跨被试EEG分类本身就是难问题。如果你的LOSO准确率接近随机水平先尝试每个被试单独做标准化再看是否有个别被试的准确率拖后腿。某些被试情绪反应弱、注意力分散单被试准确率低于随机是常见现象这时候要报告分布而不是只报均值。5. 情绪二分类的5个常见坑与排查思路5.1 全局标准化泄漏评估结果虚高现象对全量特征做StandardScaler后跑五折交叉验证准确率85%改成每折内部fit后掉到74%。原因全局fit把测试集的均值和方差写进了变换模型在训练阶段已经间接看到测试分布。这个泄漏在RBF-SVM上格外严重因为核函数完全依赖特征尺度。解决用Pipeline把标准化和分类器绑成整体从流程上杜绝漏fitfrom sklearn.pipeline import make_pipeline from sklearn.model_selection import cross_val_score pipe make_pipeline( StandardScaler(), SVC(kernelrbf, C1.0, gammascale) ) scores cross_val_score(pipe, X, y, cvskf, scoringaccuracy) print(scores.mean())Pipeline保证cross_val_score的每一折都在训练折内部执行fit测试折只走transform。以后再写分类实验scaling、PCA这些预处理步骤全部塞进Pipeline不要再手动在外面做了。5.2 baseline窗口没剔除特征混入静息信号现象时域特征分类效果不错换到频域特征后准确率反而下降或者换被试后结果崩盘。原因特征提取时把前384个baseline采样点也算进去了。baseline是无刺激的休息状态混入后模型学到的是静息状态vs情绪状态的二分类而不是情绪高低分类特征语义被污染。解决特征函数只处理baseline之后的60秒。先定义常量再切片STIM_START 384 # 3秒 * 128Hz def band_power_features_on_stim(epoch, sfreq128): stim epoch[:, STIM_START:] # 继续执行 Welch 特征提取同时做一个负向验证只对baseline段提取相同特征跑分类器如果准确率显著高于0.5说明你的pipeline没把静息信号隔离干净。5.3 切窗样本泄漏同一试次的窗进了两边现象全试次特征准确率75%切窗后样本量暴增交叉验证准确率跳到90%但换LOSO直接崩到55%。原因每段60秒切成29个窗口后相邻窗口高度相关。随机划分时同一试次的窗口会同时出现在训练集和测试集模型记住了视频片段特征而非情绪类别。解决切窗后的任何划分都必须以试次为最小单位trial_ids np.repeat(np.arange(len(windowed_X)), 29) # 每个试次29个窗 logo LeaveOneGroupOut() for tr_idx, te_idx in logo.split(windowed_X, windowed_y, groupstrial_ids): pass # 同一试次的所有窗口不会跨集合如果做普通五折也要把trial_ids作为分组传给GroupKFold而不是StratifiedKFold。5.4 类别不平衡让accuracy失真现象valence按固定阈值5切分某被试正负样本比变成1:4模型把所有样本预测为负类accuracy却高达80%。原因DEAP评分分布依被试而异全局阈值和个体实际中位数错位。accuracy指标在类别不平衡时完全失真多数类主导了得分。解决切阈值前先打印正负样本分布print(np.bincount(y))用训练集中位数替代固定阈值评估时同时看AUC和F1from sklearn.metrics import f1_score f1 f1_score(y[te_idx], y_pred)AUC对类别不平衡不敏感F1能反映少数类的召回情况。如果AUC大于0.6但F1接近0说明你的模型在牺牲少数类需要考虑类别加权或过采样。5.5 全试次平均掩盖情绪动态结果反复横跳现象同一套代码重复跑AUC在0.56到0.68之间大幅波动没有稳定结论。原因60秒视频里情绪是动态变化的全试次平均把所有时间点压缩成一个向量个体差异被平均抹掉同时放大单次伪迹段的影响。加上样本量只有1280随机种子一变结果就会晃。解决先做切窗特征并逐窗预测最后对试次内窗口做多数投票from scipy.stats import mode # window_preds shape: (num_trials, 29) trial_votes mode(window_preds, axis1).mode trial_pred trial_votes.ravel()多数投票让每个试次的最终标签由29个窗口共同决定稳定性远高于单一全试次特征。投票后准确率通常会略低于全试次最优结果但方差会明显变小结论更可信。6. 进阶验证方法从一条准确率到一整套可解释结论跑通一套SVM二分类后先别急着写结论用三把尺子量一量。第一把是时间窗迹线。把每个试次切成2秒窗、步长1秒共59个窗口逐窗提取频域特征并用训练好的分类器预测画出准确率随时间的曲线。如果后30秒准确率持续跌向0.5说明情绪诱发效应衰减或者你的特征只捕捉到了刺激起始阶段的注意反应如果前5秒就接近随机可能是被试还没进入情绪状态模型在纯噪声上瞎猜。时间窗迹线能告诉你模型真正依赖的是哪段信号比单一准确率信息量大得多。第二把是额叶不对称性检查。情绪研究有个经典假设左侧额叶alpha功率与正性情绪相关右侧额叶alpha功率与负性情绪相关。从特征矩阵里取出F3、F4通道的alpha频段功率算不对称指数(F4-F3)/(F4F3)分别看两类标签下的分布。如果两类的分布几乎完全重叠说明你这个模型学的是整体arousal或个体基线不是valence效价。这个检查能帮你判断特征方向对不对避免拿着一个高准确率模型却不知道它在学什么。第三把是负控实验。把通道索引随机打乱再提取同样的特征训练同样的分类器如果随机通道特征的准确率接近真实特征说明整条pipeline存在泄漏。我的做法是固定其他所有环节只随机置换通道名跑十次取均值作为最低可接受线。真实特征至少要比这个基线高出10个百分点。我现在每拿一个新数据集第一件事是打印标签分布和时间窗迹线第二件事才是调参。先确认特征有信号再谈模型复杂度先解决泄漏和分布问题再追求准确率数值。这套流程帮我避免过很多无效加班。希望这些经验也能帮你少踩几个坑把DEAP脑电情绪二分类做成一个稳定、可复现的实验。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进