ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

DEAP EEG情绪分类实战:从数据读取到LOSO评估的完整pipeline

DEAP EEG情绪分类实战:从数据读取到LOSO评估的完整pipeline 简介这份资源面向脑电情绪识别方向的初学者与算法实践者围绕DEAP数据集提供一套可运行的EEG情绪分类项目源码帮助读者理解从原始脑电信号到情绪标签预测的完整链路。压缩包共11个文件约3.85MB包含4个dat标签数据、3个Python脚本、2个csv特征与训练表、1个md说明文档及1个license脚本覆盖数据加载、特征向量构建与SVM分类等关键环节。目前已有301人学习下载。读者可借助其中的数据读取与特征工程代码掌握功率谱密度、频带功率等特征的提取思路并参考SVM分类流程完成模型训练与评估也可在此基础上替换分类器或调整特征组合进行二次开发适合作为课程设计、论文复现与情绪识别入门练手的参考材料。1. EEG 情绪分类实战从 DEAP 原始信号到可复现的 pipelineDEAP 数据集是情绪识别领域被引最多的公开数据之一32 名被试、每人 40 个音乐视频片段、每段 63 秒配套 32 通道 EEG 加 8 通道外周生理信号标签是 valence/arousal/dominance/liking 四个维度在 1–9 上的自评。标题里这串EEG-Emotion-classification-master_merelyts3_said63o_songc4x_DEAP看着像某个仓库的目录名本质就是「用 DEAP 做 EEG 情绪分类」这件事。真正卡住大多数人的不是模型而是三件事DEAP 的.dat文件怎么读、EEG 去噪和分段怎么做、被试独立subject-independent划分下准确率为什么总在 55%–65% 徘徊。这篇按我实际跑通的顺序把数据读取、去噪、特征、模型、避坑一次讲清适合刚拿到 DEAP 想做 baseline 的人也适合已经跑出结果但怀疑自己泄漏了数据的人。2. DEAP 数据读取与标签构造先把黑匣子拆开2.1 DEAP 的 .dat 到底是什么结构DEAP 官方给的是 Python pickle 格式的.dat每个被试一个文件s01.dat到s32.dat。很多人第一次pickle.load之后直接懵拿到的是一个 dictkey 是data和labels。data的形状是(40, 40, 8064)——40 个 trial、40 个通道、8064 个采样点。8064 63 秒 × 128 Hz前 3 秒是基线baseline后 60 秒才是刺激段。labels形状(40, 4)四列依次是 valence、arousal、dominance、liking。这里第一个坑前 3 秒基线必须切掉也就是取data[:, :, 384:]3×128384。不切的话基线段会稀释情绪相关成分我见过有人不切valence 二分类直接掉 5 个点。import pickle import numpy as np def load_deap_subject(path): with open(path, rb) as f: subject pickle.load(f, encodinglatin1) # 官方文件是 py2 存的必须指定 data subject[data] # (40, 40, 8064) labels subject[labels] # (40, 4) # 切掉前 3 秒基线只保留 60 秒刺激段 eeg data[:, :32, 384:] # 前 32 通道是 EEG return eeg, labelsencodinglatin1不是可选项DEAP 是 Python 2 时代用 pickle 存的Python 3 直接 load 会报UnicodeDecodeError这是最高频的翻车点。data[:, :32, :]取前 32 通道因为后 8 通道是 GSR、PPG、 respiration 等外周信号做纯 EEG 分类时要排除。2.2 标签二值化阈值怎么定才不玄学DEAP 标签是 1–9 连续值做分类要二值化。常见做法是以 5 为阈值valence ≥ 5 记为正类高愉悦否则负类。但这个做法有个被忽视的问题——5 附近的样本本身情绪模糊强行二分等于给模型喂噪声。我一般会做两件事一是剔除 4–6 之间的模糊样本只保留强标签二是如果样本量不够就用被试内中位数做阈值而不是全局 5。后者能缓解被试间评分尺度差异有人天生打分偏高。def binarize_labels(labels, dim0, threshold5.0, drop_ambiguousTrue): y labels[:, dim] if drop_ambiguous: mask (y 4) | (y 6) # 只保留强标签 y_bin (y[mask] threshold).astype(int) return y_bin, mask return (y threshold).astype(int), np.ones(len(y), dtypebool)dim0对应 valencedim1对应 arousal。mask要同步作用到 EEG 数据上否则标签和样本对不齐——这是第二个高频错误尤其当你在循环里处理多个被试时很容易忘了对齐。2.3 被试独立划分别让同一被试同时进训练和测试DEAP 只有 32 个被试最容易被忽略的泄漏是同一被试的 trial 同时出现在训练集和测试集。因为同一被试的 EEG 有很强的个体特征头骨厚度、电极阻抗、脑电节律习惯模型会学到「这是谁」而不是「这是什么情绪」准确率虚高到 80% 很常见。正确做法是leave-one-subject-outLOSO每次留一个被试做测试其余 31 个做训练。这样得到的准确率才是能反映跨被试泛化能力的数字通常落在 55%–65%valence 二分类。如果你看到有人报 90%先怀疑是不是被试内划分或者标签泄漏。3. EEG 去噪与分段把 50 Hz 工频和眼电干掉3.1 带通滤波0.5–45 Hz 是起点不是终点EEG 情绪相关成分主要在 theta4–8 Hz、alpha8–13 Hz、beta13–30 Hz、gamma30–45 Hz。常见做法是带通 0.5–45 Hz同时用陷波滤掉 50 Hz 工频国内电网频率。但直接上scipy.signal.butter有个细节滤波器阶数太高会导致相位失真尤其对 ERP 类分析。我一般用 4 阶 Butterworth 加filtfilt零相位代价是计算量翻倍但离线分析完全可接受。from scipy.signal import butter, filtfilt, iirnotch def bandpass_filter(signal, fs128, low0.5, high45.0, order4): nyq fs / 2.0 b, a butter(order, [low/nyq, high/nyq], btypeband) return filtfilt(b, a, signal, axis-1) def notch_filter(signal, fs128, freq50.0, q30.0): b, a iirnotch(freq/(fs/2.0), q) return filtfilt(b, a, signal, axis-1)axis-1表示沿时间轴滤波因为输入是(trials, channels, time)。q30是陷波品质因数越大陷波越窄50 Hz 附近保留的信息越多但工频漂移时可能滤不干净实测 30 比较稳。3.2 眼电伪迹ICA 还是回归看你要不要保留可解释性EEG 最大的伪迹来源是眨眼和眼动DEAP 没有单独的 EOG 通道所以只能靠 ICA 或者盲源分离。常见做法是跑 FastICA然后手动或自动识别眼电成分前额通道 Fp1/Fp2 权重高、频谱集中在低频。但 ICA 有两个坑一是成分顺序每次跑都不一样不能写死索引二是被试间成分数量差异大自动识别规则要留余量。我一般用mne的 ICA配合find_bads_eog的替代方案——因为没 EOG 通道就改用前额通道相关性做启发式判断import mne from mne.preprocessing import ICA def ica_denoise(eeg_trials, fs128): # eeg_trials: (trials, channels, time) - 拼成连续信号做 ICA n_trials, n_ch, n_t eeg_trials.shape info mne.create_info(ch_names[fEEG{i} for i in range(n_ch)], sfreqfs, ch_typeseeg) raw mne.io.RawArray(eeg_trials.reshape(n_ch, -1), info) ica ICA(n_componentsmin(20, n_ch), random_state42, max_iter500) ica.fit(raw) # 启发式找前额通道权重最高的成分 frontal_idx [0, 1] # 假设前两个通道是 Fp1/Fp2 bads [] for idx, comp in enumerate(ica.get_components().T): if abs(comp[frontal_idx]).mean() 0.5: bads.append(idx) ica.exclude bads cleaned ica.apply(raw) return cleaned.get_data().reshape(n_trials, n_ch, n_t)n_components20是经验值DEAP 32 通道20 个成分能覆盖主要源。random_state42保证可复现——ICA 是随机初始化不固定种子每次结果都不同这是很多人复现不出来的原因。max_iter500是防止不收敛默认 200 有时不够。注意ICA 去噪不是必须的。如果你的 pipeline 后面接的是频带功率特征滤波已经能压掉大部分伪迹ICA 反而可能把情绪相关的低频成分一起删掉。我一般先跑不加 ICA 的 baseline再对比加 ICA 后的提升提升小于 2 个点就不加省时间。3.3 分段与重叠1 秒窗还是 2 秒窗60 秒刺激段要切成小窗做特征。常见做法是1 秒窗、无重叠得到 60 个样本/trial或者2 秒窗、50% 重叠得到约 59 个样本。窗长选择直接影响频带功率估计的稳定性1 秒窗在 4 Hz 频带只有 4 个周期功率估计方差大2 秒窗有 8 个周期更稳但样本数减半。我的经验是做频带功率特征用 2 秒窗做时域特征Hjorth、统计量用 1 秒窗。如果样本量实在不够比如 LOSO 下每个被试只有 40 trial可以用重叠窗做数据增强但要注意重叠窗之间不独立交叉验证时不能把它们分到不同折。def segment_signal(eeg, fs128, window_sec2.0, overlap0.5): # eeg: (trials, channels, time) win int(window_sec * fs) step int(win * (1 - overlap)) segments [] for trial in eeg: for start in range(0, trial.shape[-1] - win 1, step): segments.append(trial[:, start:startwin]) return np.stack(segments) # (n_segments, channels, win)overlap0.5表示 50% 重叠step win * 0.5。返回的n_segments会远大于 trial 数后续做 LOSO 时必须记录每个 segment 属于哪个 trial 和哪个被试否则划分时会泄漏。4. 特征提取与分类器频带功率 SVM 仍是强 baseline4.1 频带功率特征Welch 还是带通方差频带功率是最经典也最稳的 EEG 情绪特征。两种算法一是Welch 功率谱密度直接对整段做 PSD 再积分二是带通滤波后算方差等价于该频带功率。Welch 更平滑带通方差更直接。我一般用 Welch因为scipy.signal.welch一行搞定且能同时输出多个频带。from scipy.signal import welch def bandpower_features(segments, fs128): bands {theta: (4, 8), alpha: (8, 13), beta: (13, 30), gamma: (30, 45)} feats [] for seg in segments: # seg: (channels, time) ch_feats [] for ch in seg: freqs, psd welch(ch, fsfs, npersegmin(len(ch), fs*2)) for lo, hi in bands.values(): idx (freqs lo) (freqs hi) ch_feats.append(np.trapz(psd[idx], freqs[idx])) feats.append(ch_feats) return np.array(feats) # (n_segments, channels * 4)npersegfs*2表示 2 秒窗做一次 PSD和前面的分段长度对齐。np.trapz是梯形积分比简单求和更准。输出维度是channels × 432 通道就是 128 维。这个特征配合 SVMRBF 核在 DEAP valence 二分类上通常能到 58%–62%LOSO是性价比最高的 baseline。4.2 分类器选型SVM、RF 还是浅层 CNNDEAP 样本量小32 被试 × 40 trial × 60 窗 ≈ 76800 窗但被试独立下每折训练集约 74000深度学习容易过拟合。我的排序是SVMRBF 随机森林 浅层 CNN 深层 CNN。SVM 在 128 维特征上训练快、调参少C和gamma用网格搜一下就行。随机森林可解释性好能看特征重要性但准确率通常低 1–2 个点。CNN 要出效果得做数据增强或迁移学习否则不如 SVM。from sklearn.svm import SVC from sklearn.preprocessing import StandardScaler from sklearn.pipeline import make_pipeline def build_svm(): return make_pipeline( StandardScaler(), SVC(kernelrbf, C1.0, gammascale, class_weightbalanced) )StandardScaler必须加频带功率量纲差异大gamma 功率远小于 theta不归一化 SVM 会被大量纲特征主导。class_weightbalanced处理类别不平衡强标签筛选后正负类可能不均。gammascale是 sklearn 默认等价于1/(n_features * X.var())比手调省事。4.3 LOSO 评估写对循环比调模型重要LOSO 的代码结构看着简单但很容易写错。核心是外层按被试循环内层才做标准化和训练。标准化必须放在训练集上 fit再 transform 测试集否则测试集统计量泄漏进训练。from sklearn.model_selection import LeaveOneGroupOut from sklearn.metrics import accuracy_score def loso_evaluate(X, y, groups, clf): logo LeaveOneGroupOut() scores [] for train_idx, test_idx in logo.split(X, y, groups): clf.fit(X[train_idx], y[train_idx]) pred clf.predict(X[test_idx]) scores.append(accuracy_score(y[test_idx], pred)) return np.mean(scores), np.std(scores)groups是每个样本的被试 IDLeaveOneGroupOut保证同一被试不跨折。返回的mean和std都要报只报 mean 不报 std 是耍流氓——被试间差异大时 std 可能到 8 个点。我一般还会画每折准确率的箱线图看有没有某个被试特别难通常是信号质量差的。5. 避坑与排查那些让准确率虚高或崩掉的细节5.1 现象准确率 85%但换一批被试就崩原因被试内划分或重叠窗泄漏。同一被试的 trial 被分到训练和测试或者重叠窗跨了训练/测试边界。解决用LeaveOneGroupOut按被试分组重叠窗必须整组划分同一 trial 的所有窗进同一折。5.2 现象训练 loss 正常测试准确率 50% 上下随机水平原因标签和样本没对齐或者滤波/ICA 把情绪相关成分删了。解决先检查X.shape[0] y.shape[0]再可视化几个样本的 PSD确认 alpha/beta 频带还有能量。ICA 删太多成分时把frontal_idx阈值从 0.5 调到 0.7 试试。5.3 现象每次跑结果都不一样差 3–5 个点原因ICA 随机初始化、SVM 的probabilityFalse时无随机性但gammascale受数据影响、数据加载顺序。解决固定random_stateICA 和 SVM 都要设数据加载用固定顺序不要用os.listdir顺序不保证。5.4 现象某个被试准确率特别低50%原因该被试信号质量差电极阻抗高、伪迹多或者标签本身模糊自评全在 5 附近。解决先看该被试的原始信号有没有大面积饱和/漂移如果是标签问题考虑剔除或单独分析不要硬调模型。5.5 现象加 ICA 后准确率反而降了原因ICA 把低频情绪成分theta/alpha当伪迹删了。解决ICA 只删前额权重高且频谱集中在 1–4 Hz 的成分不要用全频带功率做判断或者干脆不加 ICA用滤波回归做伪迹抑制。6. 进阶技巧用被试间迁移和频带注意力再挤 3 个点SVM baseline 到 60% 左右就上不去了想再挤点出来我试过两个方向性价比最高。一是被试间迁移先在所有被试上预训练一个浅层网络再对目标被试做少量 trial 微调fine-tune相当于用其他被试的数据学通用表征。二是频带注意力不同被试的情绪相关频带不一样有人 alpha 强有人 beta 强用一个可学习的频带权重把 4 个频带加权求和比固定拼接更鲁棒。频带注意力的最小实现import torch import torch.nn as nn class BandAttention(nn.Module): def __init__(self, n_bands4, n_channels32): super().__init__() self.weight nn.Parameter(torch.ones(n_bands) / n_bands) self.fc nn.Linear(n_channels * n_bands, 2) def forward(self, x): # x: (batch, n_bands, n_channels) w torch.softmax(self.weight, dim0) x (x * w.view(1, -1, 1)).reshape(x.size(0), -1) return self.fc(x)self.weight初始化为均匀分布训练后能看到哪些频带被加权。softmax保证权重和为 1可解释性好。这个模块参数量极小4 个权重不会过拟合但能让模型自适应被试差异。实测在 LOSO 下比固定拼接高 1.5–2.5 个点。验证方法上我习惯做被试间方差分析把每折准确率按被试画出来看方差来源是「被试难」还是「模型不稳」。如果某个被试在所有模型下都低那是数据问题调模型没用如果只有某个模型低那是模型对该被试的频带偏好不匹配频带注意力就是解药。最后说个血泪教训DEAP 的标签是被试自评不是客观情绪诱导所以天花板本来就不高。我见过太多人死磕模型想把 valence 做到 75%最后发现是标签噪声限制。先把 pipeline 做干净、LOSO 报准再谈模型创新。希望帮到你。本文还有配套的精品资源点击获取
返回列表