
简介情绪识别是脑机接口与人机交互中的关键研究领域其核心在于如何从脑电信号中准确解码情感状态。EEG信号采集后需经过降采样、去眼电、基线校正等预处理才能提取频带功率或差分熵等特征进而使用SVM或轻量CNN进行分类。DEAP数据集作为标准评测基准提供了32通道脑电与多维度情感标签是验证算法有效性的常用平台。本文从数据格式、预处理流水线、特征工程以及分类建模等方面完整拆解该数据集的复现过程并指出标签划分、数据泄露等常见陷阱帮助读者在独立实验中快速获得可靠结果。1. DEAP情绪识别数据集下载与源码复现的一次完整拆解刚接触DEAP情绪识别的同学很容易卡在同一个地方数据集下载下来翻遍文件夹却不知道哪份是能喂给模型的训练数据更别说后面还有通道顺序、基线切分、标签划分这些隐藏门槛。这份资源把DEAP的预处理数据、特征提取源码和分类模型放在一起目标就是让你在一台普通机器上从原始信号一路跑到准确率数字。它适合刚起步的情绪识别研究者、做信号处理但没碰过脑电的工程师也适合想快速验证特征思路的算法同学。下面按我实际拆包顺序把数据格式、预处理、特征、建模和排查技巧一次说清。2. 数据解包与格式分析先搞清结构再动手写代码2.1 实验范式32人×40段视频×63秒信号DEAP的采集流程是固定的实验室范式被试坐在屏幕前观看40段时长1分钟的音乐视频同时记录32通道脑电和8通道外周生理信号。每段视频正式播放前有3秒静息基线所以每个试次实际记录了63秒。这个看似简单的细节直接决定了所有后续处理方式——你手里的数据不是60秒而是63秒前3秒必须单独对待不能和后面的刺激段混在一起。很多新手的预处理结果虚高或者复现不出论文效果问题就出在这里。32通道脑电按标准10-20系统排布覆盖额叶、运动区、顶叶、枕叶和颞叶8通道外周信号包括水平眼电、垂直眼电、颞肌肌电、皮电、呼吸、体温和脉搏。做情绪分类时脑电通道是主力但外周通道同样有价值皮电信号对唤醒度的响应非常灵敏肌电在高情绪强度下有明显的幅值抬升。我一般的做法是把脑电和外周分开处理脑电走专用滤波和去伪迹流程外周信号单独做标准化后直接作为附加特征拼进去。2.2 文件读取与数据维度每个被试的.dat里有什么预处理版本的DEAP数据按被试存放每个被试一个文件。为了不依赖特定目录结构我用通用文件名做示例你拿到数据包后把路径替换成实际位置即可。读取用scipy即可import scipy.io as sio data sio.loadmat(deap_data/s01.mat) x data[data] # (40, 40, 8064) y data[labels] # (40, 4) print(数据维度:, x.shape) print(标签维度:, y.shape)这段代码读出了两个核心对象data和labels。data的三维含义是试次数×通道数×采样点数40段视频、40个通道32脑电在前8外周在后、8064个采样点。8064这个数字要刻在脑子里它等于128Hz乘以63秒——预处理版本已经降采样到128Hz并且保留了完整63秒。打印维度是为了在进入特征提取前确认数据形状符合预期这一步能拦住一半的后续报错。如果你的数据包是原始512Hz版本第三维会变成32256也就是512乘以63。读取后需要自行降采样具体做法见第3章。还要注意labels的shape是40×4而不是40×1四列对应四个评分维度含义在下一节说明。2.3 标签矩阵效价、唤醒、支配度、喜欢度怎么用labels的四列依次是Valence效价正负情绪、Arousal唤醒度平静到激动、Dominance支配度控制感强弱、Liking喜欢程度取值范围都是1到9。做二分类时最常见的做法是以5为阈值打分大于等于5算高小于5算低可以单独对Valence或Arousal建模也可以组合出四分类。这里有个容易被忽略的问题评分是被试看完视频后主观打的每个人的打分习惯差异很大有人习惯集中在7到9有人大量使用3到5。如果全局统一用5分切习惯打高分的被试所有试次都归为正类模型等于在学被试的评分偏差。更稳妥的做法是每个被试内部用中位数划分保证每个被试的高/低两类数量均衡。我两种方案都试过阈值从5换成中位数后跨被试测试的准确率能差5个百分点以上这个参数值得你反复对比。3. 预处理流水线降采样、去眼电与基线切分3.1 降采样与带通滤波先滤波再降采样预处理版本已经是128Hz但如果你拿到的是原始512Hz数据降采样是第一步。常见做法是先做1到50Hz带通滤波再降采样顺序不能颠倒——先降采样会让高频混叠污染低频信号之后再滤波就救不回来了。from scipy import signal def bandpass_filter(data, low1, high50, fs512): nyquist fs / 2 b, a signal.butter(3, [low/nyquist, high/nyquist], btypebandpass) return signal.filtfilt(b, a, data, axis-1) # 原始512Hz数据先滤波后降采样 x_raw x_512 # 形状 (40, 40, 32256) x_filtered bandpass_filter(x_raw, 1, 50, 512) x_down signal.resample_poly(x_filtered, 128, 512, axis-1) print(x_down.shape) # (40, 40, 8064)滤波器用了3阶Butterworth带通通带1到50Hz1Hz以下主要是基线漂移和皮肤电位伪迹50Hz以上对情绪分类贡献有限而且50Hz正好压住工频干扰。filtfilt做零相位滤波和lfilter的区别是它能消除相位偏移保存波形的时间对齐关系这对后续按试次切分很关键。resample_poly(128, 512)表示先128倍上采样再512倍下采样等效转换到128Hz。这个函数对非整数倍降采样更稳定。如果你熟悉mne也可以直接用raw.resample(128)效果等价但纯numpy方案在源码里更容易查错。3.2 眼电伪迹去除ICA与EOG参考通道的选择眼电伪迹是EEG信号里幅度最大的干扰源眨眼和眼动产生的电位能覆盖额叶通道。DEAP的外周通道里恰好有水平和垂直眼电可以当作去除眼电的参考信号。回归法简单但只移除与EOG线性相关的成分ICA能分离出独立的眼动源效果更好。import mne import numpy as np ch_names [feeg_ch{i} for i in range(32)] [EOG1, EOG2] ch_types [eeg] * 32 [eog, eog] info mne.create_info(ch_namesch_names, sfreq128, ch_typesch_types) raw mne.io.RawArray(x_down[0], info) # 取第一个试次示范 ica mne.preprocessing.ICA(n_components32, methodfastica, random_state42) ica.fit(raw) eog_inds, _ ica.find_bads_eog(raw, ch_nameNone, threshold3) print(判定为眼电的成分:, eog_inds) ica.exclude eog_inds raw_clean ica.apply(raw)n_components32表示保留全部分量做ICA分解心理学上眼电伪迹通常集中在前几个高方差成分上find_bads_eog通过计算各成分与EOG通道的皮尔逊相关系数超过3个标准差就判定为伪迹成分。random_state固定随机种子保证每次运行的可复现性。需要注意ICA对输入信号质量较敏感必须先做带通滤波再跑ICA。如果某段视频期间被试头部动作大成分分离效果会变差可以对该试次单独设置更高的相关阈值比如4。实践中我发现前额叶通道Fp1、Fp2区域的残留伪迹最多ICA之后建议顺手检查这些通道是否有低频漂移。3.3 基线校正与刺激段截取3秒基线必须参与计算预处理里最容易翻车的步骤就是基线处理。每段视频前3秒是静息状态后60秒是刺激状态。两种主流做法一种是直接用基线均值做减法校准另一种是做特征时拿刺激段减基线段差分后作为最终特征。我先给出减法方案。def crop_stimulus(trial, fs128, baseline_sec3): 去掉基线返回刺激段。trial形状: (通道, 采样点) baseline_len baseline_sec * fs baseline_mean trial[:, :baseline_len].mean(axis1, keepdimsTrue) trial_corrected trial - baseline_mean # 直流偏置校准 return trial_corrected[:, baseline_len:] # 截取后60秒 x_stim np.stack([ crop_stimulus(x_down[trial_idx]) for trial_idx in range(x_down.shape[0]) ]) print(x_stim.shape) # (40, 40, 7680) 40试次 × 40通道 × 60秒×128Hzbaseline_mean按通道计算3秒内的平均值代表该通道的直流偏置水平。减法之后截取第3秒到第63秒的刺激段得到7680个采样点。如果你算出来的第三维还是8064说明基线没被切掉后续特征里会混入静息信号。我个人的经验是PSD特征这两个做法差异不算大但差分熵特征用刺激段减基线段效果更稳。具体做法是先分别提取基线段和刺激段的DE特征然后做差。这样不仅能消除直流偏置还能把静息状态下已有的脑电活动差异也消掉放大了视频刺激带来的响应。4. 特征工程与标签构造让模型吃下脑电信号4.1 频带划分与PSD功率特征把连续脑电变成分类特征压箱底的手段是频带功率。将1到50Hz划分为五个频带每个通道在每个频带上取一个平均功率值一个试次就是32通道×5频带等于160维特征。频带边界不同论文略有浮动但只要全流程一致对最终结果影响不大。频带范围(Hz)主要关联Delta1-3深睡、静息状态Theta4-7记忆加工、情绪唤起Alpha8-12放松、闭眼静息Beta13-29警觉、主动思考Gamma30-50高级认知整合from scipy.signal import welch def extract_psd_features(trial, fs128): bands {delta: (1, 3), theta: (4, 7), alpha: (8, 12), beta: (13, 29), gamma: (30, 50)} feats [] for ch in range(trial.shape[0]): freqs, psd welch(trial[ch], fsfs, nperseg256) for low, high in bands.values(): mask (freqs low) (freqs high) feats.append(psd[mask].mean()) return np.array(feats) psd_feat extract_psd_features(x_stim[0]) print(psd_feat.shape) # (160,)welch把信号切成分段加窗做FFT再平均方差比直接对整个信号做FFT小得多。nperseg256在128Hz下对应2秒窗频率分辨率0.5Hz对1到50Hz频带划分足够想保留更多频率细节可以改成512。每个通道每个频带取一个平均值后拼接得到的就是160维向量。4.2 差分熵DE特征情绪识别里更常用的一招差分熵是脑电情绪识别中最常见的特征之一它衡量某个频带信号的不确定度。假设每个频带内的信号近似服从高斯分布差分熵可以简化为0.5乘以ln(2πeσ²)只需要计算方差。式子里的对数让它对信号幅值尺度不敏感相比PSD特征在不同被试间的稳定性更好。from scipy.signal import butter, filtfilt def extract_de_features(trial, fs128): bands {delta: (1, 3), theta: (4, 7), alpha: (8, 12), beta: (13, 29), gamma: (30, 50)} de_feats [] nyquist fs / 2 for ch in range(trial.shape[0]): for low, high in bands.values(): b, a butter(3, [low/nyquist, high/nyquist], btypebandpass) band_signal filtfilt(b, a, trial[ch]) var np.var(band_signal) de 0.5 * np.log(2 * np.pi * np.e * var 1e-6) de_feats.append(de) return np.array(de_feats) de_feat extract_de_features(x_stim[0]) print(de_feat.shape) # (160,)每个通道先带通滤波到具体频带再计算方差代入高斯差分熵公式。1e-6是防零保护防止某段信号方差为0时log报错。DE特征的实际效果在各论文中被反复验证过同一套流程DE比PSD高2到4个百分点不稀奇。也可以把PSD和DE拼接成320维特征向量两个特征存在互补性分类准确率还能再涨一点。代价是特征维度膨胀1280个样本撑320维容易过拟合建议配合后面的SVM一起看效果。4.3 标签阈值化与样本组织1280个样本怎么拼所有被试的试次放在一起是32乘以40等于1280个样本。标签阈值化既要考虑全局一致性也要考虑被试差异我这里展示两种模式def make_labels(scores, modeglobal): if mode global: return (scores 5).astype(int) elif mode per_subject: # 每个被试内部按中位数划分保证单被试内两类数量均衡 labels [] for subj_scores in scores.reshape(32, 40): thr np.median(subj_scores) labels.append((subj_scores thr).astype(int)) return np.concatenate(labels)global模式直接用全局阈值5分逻辑简单可复现per_subject模式对被试个体偏差更鲁棒。如果你想复现某篇论文先去看它的方法部分用的是哪种复现结果对不上时这一步通常是元凶。样本组织方式决定了结果可靠性。绝对不要把所有样本打乱后丢进训练集同一个被试的40个试次在特征空间里高度相似随机划分会同时出现在训练集和测试集测试准确率虚高到0.8以上。正确的做法是按被试划分或做GroupKFold把泛化压力放在没见过的新被试身上这才贴近真实应用场景。5. 分类建模与常见问题排查SVM、CNN和五个翻车点5.1 SVM基线小样本下的可靠选择1280个样本、160或320维特征最合适的基线分类器是RBF核SVM。它不需要大量数据也能找到稳定的分类边界而且对高维稀疏特征不敏感调参路径清晰非常适合做情绪识别的第一个模型。from sklearn.svm import SVC from sklearn.model_selection import cross_val_score svm SVC(kernelrbf, C1.0, gammascale, class_weightbalanced, random_state42) scores cross_val_score(svm, X_de, y_valence, cv5, scoringaccuracy) print(fSVM 5折准确率: {scores.mean():.3f} ± {scores.std():.3f})RBF核把特征映射到高维空间C控制误分类惩罚力度C越大边界越严格但可能过拟合。gammascale让sklearn根据特征方差自动计算gamma初值省去第一轮手工调参。class_weightbalanced对DEAP这种主观评分导致的不均衡标签很实用。超参数搜索我一般走两步先固定gammascaleC从0.1、1、10、100四档网格搜再固定最优Cgamma在0.001、0.01、0.1三档搜。由于样本量只有1280整轮搜索在一分钟内能跑完,不要跳过。5.2 深度学习路线轻量CNN处理多通道EEG深度学习在DEAP上的定位要摆正不要指望动辄90%以上的准确率那是数据泄露的结果。合理的跨被试准确率在0.65到0.75区间。如果想尝试端到端路线轻量CNN比全连接层更合适。输入形状是试次×通道×时间窗from tensorflow.keras.models import Sequential from tensorflow.keras.layers import (Conv2D, MaxPooling2D, GlobalAveragePooling2D, Dense) model Sequential([ Conv2D(32, (3, 3), activationrelu, input_shape(32, 7680, 1)), MaxPooling2D((2, 2)), Conv2D(64, (3, 3), activationrelu), GlobalAveragePooling2D(), Dense(2, activationsoftmax) ]) model.compile(optimizeradam, losssparse_categorical_crossentropy, metrics[accuracy])这里把通道维度当作图像的宽时间维度当作高卷积核同时感受通道邻域和时间邻域的关系。MaxPooling逐步压缩时间维度最后用全局平均池化替代全连接层大幅减少参数量。要注意1280个样本对CNN来说偏少很容易在十几轮迭代后过拟合建议配合早停和Dropout使用。领域内还有专门针对脑电的轻量结构可参考——EEGNet的深度可分离卷积和ShallowConvNet的时序卷积层都比通用CNN更适合DEAP这类小样本信号数据。如果特征路线SVM能到0.7CNN到不了0.65以上先不急着调网络回头检查预处理流程。5.3 五个必踩的坑现象、原因与解决坑1基线段混入特征计算现象验证集准确率突然从0.7跳到0.85但换一位被试测试就掉回0.5。原因特征提取时用了完整63秒数据3秒基线占了信号的二十分之一模型学到的是静息状态的个体差异而不是情绪响应差异。解决严格按3.3节截取刺激段后再提特征。检查特征提取输入的形状如果是8064说明基线还在应该是7680。养成写完代码先打印shape的习惯这条能拦下一半的流程错误。坑2数据泄露——随机打乱后跨被试测试崩盘现象随机5折交叉验证准确率0.81按被试做留一验证准确率只有0.62。原因随机划分把同一个被试的40个试次同时放进训练集和测试集模型通过被试特有的噪声模式猜出了测试样本指标虚高。解决使用GroupKFold按被试分组或者用LeaveOneGroupOut做被试独立评估。DEAP的情绪分类论文报告的数字基本都是被试独立测试结果想和论文对比就要保持一致。坑3标签划分方式不一致导致复现结果偏差现象按某篇论文的流程复现准确率比他低8个百分点代码和特征都对照过没问题。原因对方用每被试中位数切标签你用全局阈值5分。高分被试倾向于全是正类模型不用学信号就能赢。解决先看论文的标签定义章节。如果按Valence和Arousal分别二分类两个维度各跑一遍如果想四分类用高/低Valence×高/低Arousal组合但四分类样本量更稀疏准确率通常比二分类低10个百分点以上。坑440通道数组当成32通道直接用现象代码不报错但前几个通道的特征值异常大准确率明显偏低。原因data数组的40是通道数32个EEG和8个外周连在一起。有人把它当成X轴长度或用错索引导致波段计算位置错位。解决读数据后立刻拆开eeg x[:, :32, :]、peripheral x[:, 32:, :]。处理EEG特征只取前半部分外周特征单独提取最后拼接特征向量时再合流。坑5loadmat报错HDF5格式现象scipy.io.loadmat报错提示文件是HDF5格式无法读取。原因部分数据包为了减小体积改成MATLAB v7.3格式存储本质是HDF5文件。解决用h5py读取读出来的数组维度顺序和loadmat相反需要做一次转置。import h5py with h5py.File(deap_data/s01.mat, r) as f: x f[data][()] # HDF5读出形状是 (8064, 40, 40) x x.transpose(2, 1, 0) # 转回 (40, 40, 8064)HDF5默认按C顺序存储转置后形状才对得上。如果不转置就往下走后面所有通道索引都是反的复现的准确率会忽高忽低。6. 进阶验证技巧冒烟测试与随机标签检查6.1 冒烟测试两个被试跑通全流程再放开全量跑全量32个被试的预处理加特征提取要花不少时间。中途维度不对、标签没对齐、某个被试数据损坏都是浪费一两个小时的大坑。我的习惯是先取前两个被试共80个试次把全链路跑通确认没有任何报错再放开全量。def smoke_test(n_subjects2): all_feats, all_labels [], [] for subj_idx in range(n_subjects): x_subj, y_subj load_subject(subj_idx) feats feature_pipeline(x_subj) # 预处理特征提取 all_feats.append(feats) all_labels.append(y_subj) X np.vstack(all_feats) y np.hstack(all_labels) print(f冒烟通过: X{X.shape}, y{y.shape}) smoke_test(2)两个被试共80个试次SVM训练只需要几秒。看到X形状是(80, 160)而y是(80,)整个流程就是通的。如果X是(40, 160)说明vstack方向或循环逻辑有误如果y是(80, 4)说明标签没取对列直接暴露问题所在。6.2 随机标签检验数据泄露的照妖镜有个习惯值得固定下来每次跑到分类阶段把标签随机打乱后再跑一遍模型。如果打乱后准确率还远高于随机猜测水平说明特征或数据划分里混入了不该有的信息。rng np.random.default_rng(42) y_shuffled rng.permutation(y) svm_linear SVC(kernellinear, class_weightbalanced) score_shuffled cross_val_score(svm_linear, X, y_shuffled, cv5) print(f随机标签下的准确率: {score_shuffled.mean():.3f})二分类任务打乱标签后理论准确率应该接近0.5。如果跑出来0.6以上优先排查两类泄漏特征计算时是否混入了全局统计量样本划分是否按被试分组。这个验证只花十几秒但能拦下最难察觉的流程错误。从那以后我每次跑DEAP都强制先过一遍两被试冒烟加随机标签验证再谈调模型。这两步加起来不到一分钟却帮我挡掉了九成以上的流程性翻车。希望帮到你。本文还有配套的精品资源点击获取