简介:面向情感识别研究者与入门者,这份基于DEAP数据集的处理与分类代码包,解决了从原始生理信号到特征提取、降维及SVM识别的完整流程搭建问题。资源共19个文件,以13个Matlab脚本为核心,覆盖数据读取(loadData.m、makeDEAPstruct.m)、电极映射(getElectrodeMapping.m)、频段特征构建(freqBands.m、extractFeaturesFromPSD.m)、受试者特征聚合与分类(ClassifySVM.m),另含4个asv自动备份、1份README说明与gitignore文件,压缩包整体仅11KB,便于快速阅读和移植。已有2501人学习,适合需要通过代码结构复现DEAP情感识别实验的Matlab用户;到手后可直接对照脚本梳理从原始数据到SVM分类的完整步骤,也可基于现有特征工程与降维函数做二次改造。
1. DEAP数据集到底是什么:为什么情感识别入门绕不开它
做情感识别最头疼的往往不是模型,而是数据。DEAP数据集正是这个场景里使用频率最高的公开生理信号库:32名被试各观看40段一分钟音乐视频,同步记录32导脑电和8路外周生理信号,每段视频结束后由被试自己对效价、唤醒度、优势度和喜爱度打分,由此得到一套带情感标注的生理信号样本。这个结构让DEAP成了情感识别领域的事实基准——论文对基准、课程做设计、入门练手,几乎都从它开始。它要解决的核心问题是:给你一份带真实情感标签的EEG数据,让你把预处理、特征提取和分类模型的整条链路跑通。适合想入门情感识别、做课程项目或验证新模型的从业者。下面按一条可复现的路径展开:先讲数据格式,再做预处理和特征,最后给模型评估与踩坑记录。
2. 先拿到数据再说:DEAP文件结构、.mat加载与形状确认
下载解压之后,第一步不是写特征提取,而是把文件结构摸清楚。DEAP的预处理数据以 .mat 格式分发,这种格式在 Python 里用 scipy 就能读,但数据组织方式有合并版和单被试版两种,搞混了后面全盘皆输。
2.1 合并文件与单被试文件怎么选:先看懂 data 和 labels 两个字段
DEAP 预处理数据通常有两种分发形式:一种是所有被试打包在一个文件里,另一种是按被试拆成32个文件。两者内容一致,只是组织方式不同,具体差别看下表。
| 版本 | 文件示例 | data 形状 | labels 形状 | 适用场景 |
|---|---|---|---|---|
| 合并版 | data_preprocessed_matlab.mat | (32, 40, 40, 8064) | (32, 40, 4) | 想一次加载全部被试 |
| 单被试版 | s01.mat 到 s32.mat | (40, 40, 8064) | (40, 4) | 内存有限、按被试逐一处理 |
先解释四个维度的含义。以合并版为例,data 的 shape 依次是被试数、试次数、通道数、采样点数:32名被试、每名40个试次,所以前两维是32和40;通道维是40,其中前32个是脑电通道,按国际10-20系统排布,后8个是外周生理信号,大致涵盖肌电、眼电、皮电、呼吸、脉搏和体温;采样点维是8064,对应128Hz采样率下63秒时长,这63秒由3秒静息基线和60秒视频组成。labels 的最后一维4表示效价、唤醒度、优势度、喜爱度四个评分,取值都在1到9之间。
这里有个非常容易踩的误解:很多人以为 labels 是视频本身的情感标签。实际上预处理文件里存的是每位被试自己看完后的主观打分,不是视频的群体平均评分。如果想让所有被试共用一个标签,需要去读单独的评分文件;而直接用数据包里的 labels,训练出来的模型学习的是个体真实感受,这恰恰是情感识别更关心的目标。
2.2 用Python加载DEAP的.mat文件并确认shape
import scipy.io as sio import numpy as np mat = sio.loadmat('data_preprocessed_matlab.mat') data = mat['data'] # 合并版预期 (32, 40, 40, 8064) labels = mat['labels'] # 合并版预期 (32, 40, 4) print('data 形状:', data.shape, 'dtype:', data.dtype) print('labels形状:', labels.shape, '取值范围:', labels.min(), labels.max())加载完成后我建议立刻做三个自检。第一,打印 data.dtype,正常是 float64,全量加载约占用3.3GB内存,如果你的机器只有16GB内存还开着浏览器,这一步就会卡到怀疑人生。第二,确认 labels 最小值是1、最大值是9,说明是原始打分而不是已经二值化过的标签;如果 min 是0、max 是1,说明你下到了别人二次处理过的版本,后面阈值逻辑全部要改。第三,看 data.shape 最后一个维度是不是8064,如果不是,先做 squeeze 去掉单例维度再看,很多 .mat 文件会多出一两个长度为1的维度,直接 reshape 会搞乱数据。
如果你是单被试版,把加载路径换成 s01.mat,data 的形状是 (40, 40, 8064),前两维分别是试次和通道,没有被试维。这时处理逻辑要套一层 for 循环,逐个被试加载、处理、释放,内存压力小得多。
提示:如果 loadmat 报错且错误信息里出现 v7.3 或 HDF5,说明文件是 Matlab 新格式。DEAP 常规 .mat 文件用 scipy 直接读没问题,但网盘二次打包的版本偶尔会变成新格式,那就要改用 h5py,访问方式从 mat['data'] 变成 f['/data'],并且返回的是 dataset 对象,要先取 [:] 再转 numpy 数组。
2.3 转成npy缓存:实验前先花两分钟,后面省几十分钟
EEG 实验的特点是反复改参数、反复重跑,每次从头 loadmat 一个几GB的数组非常浪费。我一般会在第一次加载成功后立刻转成 npy 缓存,后面所有实验直接读缓存。
# 第一次加载完成后执行一次,之后注释掉 X = data.astype(np.float32) Y = labels.astype(np.float32) np.save('deap_X.npy', X) np.save('deap_Y.npy', Y) # 之后每次实验这样加载 X = np.load('deap_X.npy', mmap_mode='r') Y = np.load('deap_Y.npy', mmap_mode='r')参数说明:astype(np.float32) 把 float64 降成 float32,精度对后续特征计算完全够用,内存从3.3GB直接降到1.65GB;mmap_mode='r' 是只读内存映射,加载时不会把整个数组物理读进内存,而是用到哪块读哪块,配合按被试循环的实验非常合适。注意 mmap 模式下的数组不能原地修改,需要先取切片或拷贝再操作。如果特征提取代码改了、需要重新生成特征,直接删掉 npy 重跑缓存那一步即可,不用动原始 .mat。
3. 预处理三步:基线扣除、带通滤波与通道顺序
预处理决定了后续特征的天花板。这一步的目标是把原始信号里的个体差异、工频干扰和通道顺序问题一次处理干净,让后面的特征提取在一个稳定的信号基础上进行。常见做法是三步:扣基线、带通滤波、拆通道。
3.1 三秒静息基线为什么必须扣:不扣基线模型记住的是人不是情绪
每个试次的8064个采样点里,前384个,也就是3秒乘以128Hz,是静息基线。这一小段记录的是被试在视频开始前的状态,它存在的意义是给后续60秒的情感响应提供一个参照基准。
EEG 的绝对幅值受个体差异影响非常大:有的人静息时枕区 alpha 波幅值天生就高,有的人状态紧张时额区 beta 持续偏高。如果直接拿整段信号做特征,模型真正学到的很可能不是"这段视频引发了什么情绪",而是"这段信号来自哪个人"。这也是为什么很多新手第一次跑 DEAP 准确率异常高——模型在做变相的被试识别。
标准做法是把基线当作零点:对每个通道,先计算前384个采样点的均值,再从整个试次中减去。这样后续特征描述的是"看视频相对于静息状态的变化量",个体差异被大幅压制。另一类做法是在频域扣基线,用基线段的功率谱逐频点去减试次功率谱,效果更精细但实现复杂,我一般先用时域均值减法,简单稳定,对二分类任务足够。
3.2 滤波参数怎么定:EEG四个频段与4-45Hz带通
EEG 的特征基本都按频段组织。DEAP 预处理后的采样率是128Hz,奈奎斯特频率64Hz,所以能用的分析范围最高到45Hz左右。常用频段划分如下。
| 频段 | 频率范围 | 常见关联 |
|---|---|---|
| theta | 4-8 Hz | 放松、困倦、情绪唤醒 |
| alpha | 8-13 Hz | 静息、放松、注意力抑制 |
| beta | 13-30 Hz | 活跃思考、紧张、警觉 |
| gamma | 30-45 Hz | 高级认知加工、情绪强度 |
滤波前先想清楚目的。如果只做情绪二分类,一个4-45Hz的带通就够了,它能去掉1Hz以下的慢漂移和运动伪迹,也能把45Hz以上的肌电干扰压下去,同时保留四个频段的完整信息。如果后面要做特定频段分析,再分别滤波,不要一开始就把信号切成多份,费内存不说,还会让流程变得难排查。工频干扰是50Hz,正好落在这个带通之外,所以多数情况下不需要额外陷波,如果发现50Hz附近还有明显尖峰,再单独加一个陷波器。
3.3 完整预处理函数:从原始试次到干净信号
from scipy import signal def preprocess_trial(trial, fs=128, baseline_sec=3, band=(4, 45)): """trial: 一个试次的信号,形状 (通道数40, 采样点数8064)""" n_base = fs * baseline_sec # 384 个采样点 base_mean = trial[:, :n_base].mean(axis=1, keepdims=True) trial = trial - base_mean # 每个通道分别扣基线均值 b, a = signal.butter(4, band, btype='bandpass', fs=fs) trial = signal.filtfilt(b, a, trial, axis=1, padlen=150) return trial这段代码里的参数都值得记一下。butter 阶数取4,阶数越高过渡带越陡,但相位畸变越大,4阶在脑电里是常用折中点。filtfilt 做的是零相位滤波,正向滤一遍、反向再滤一遍,把相位失真抵消掉,这是 EEG 预处理里几乎必选的做法,不要用 lfilter 替代,否则波形会整体偏移。band 是 (4, 45),下限不能设成0,否则直流漂移和运动伪迹都会留下。padlen=150 是边缘延拓长度,filtfilt 默认在边缘补零,补得不够时试次前后几百个点会出现明显振铃,经验值是滤波器阶数的30到40倍,4阶滤波器给150左右比较稳妥。
处理完记得把通道拆开:data 的前32行是脑电,后8行是外周信号。只做EEG实验就取 [:32],做多模态融合才保留全部40通道。通道顺序问题我在实际项目里吃过亏——有次特征维度对不上,排查半天发现是代码直接取了数据尾部当脑电,结果真正用的是肌电和皮电,模型当然学不出稳定的情绪模式。
4. 特征提取与建模:从每试次8064个采样点到情绪二分类
预处理完,每个试次还是 (40, 8064) 的原始信号,分类器不能直接吃这种输入。下一步是把一段高维时序信号压缩成低维特征向量,再配上标签训练模型。常见做法是功率谱密度和差分熵,模型先用 SVM 和随机森林打底。
4.1 Welch功率谱密度特征:每通道每频段一个值
功率谱密度 PSD 是EEG情感识别里最基础的特征。它回答的问题是:每个通道的信号能量主要分布在哪些频率上。Welch 法是最常用的 PSD 估计方式,本质是把信号切段、各段做FFT、再取平均,降低单次FFT的方差。
freqs, psd = signal.welch(clean_trial, fs=128, nperseg=512, noverlap=256) bands = {'theta': (4, 8), 'alpha': (8, 13), 'beta': (13, 30), 'gamma': (30, 45)} feat_parts = [] for name, (lo, hi) in bands.items(): mask = (freqs >= lo) & (freqs < hi) feat_parts.append(psd[:, mask].mean(axis=1)) # 每个通道一个均值 feature = np.concatenate(feat_parts) # 40通道 × 4频段 = 160维参数说明:nperseg=512 表示用512个点做一次FFT,对应4秒窗,频率分辨率等于 fs 除以 nperseg,算下来是0.25Hz,频段边界能正好落在整数频率上;noverlap=256 是50%重叠,60秒信号能切出约29个窗,重叠越多功率估计越平滑,但计算量也线性上涨。psd 的形状是 (通道数, 每个通道的频点),对频段范围取平均后,每个通道得到4个代表值,40通道拼起来就是160维特征;只用EEG就取前32通道,得到128维。
这里有个细节:PSD 不同频段的数值量级差异很大,gamma 段功率普遍比 theta 低几个数量级。后续接 SVM 时必须靠标准化拉齐量级,接树模型时不敏感,但无论如何都不要把原始频谱直接拼成长向量喂给模型——维度高、噪声大、解释性差,这是新手最容易犯的偷懒写法。
4.2 差分熵与标签二值化:1-9分量表怎么切成高低类
PSD 之外,DEAP 情感识别里另一个高频特征是差分熵 DE。它衡量信号在某个频段上的复杂度,对脑电的平稳段识别效果不错。工程上有个简化公式:假设频段内信号近似服从高斯分布,差分熵等于 0.5 乘以 log(2πeσ²),其中 σ² 就是该频段的平均功率,所以可以直接从 PSD 推出来。
eps = 1e-10 de_feature = 0.5 * np.log(2 * np.pi * np.e * (freq_band_power + eps))实际使用中,很多人直接取 log(频段功率) 作为特征,效果差别不大。注意功率必须加保护值 eps,否则某些通道功率接近0时 log 会输出负无穷,整条特征直接报废。这个细节不贵,但能救一整个实验。
标签处理同样关键。labels 是1到9的连续打分,做分类任务需要二值化。最常见的切法是打分大于等于5归为高一类、小于5归为低一类,对效价和唤醒度分别切。但这个固定阈值有个问题:每个人的打分习惯不同,有人全程打7以上,有人集中在4到6,固定阈值5会制造严重的类别不平衡。更稳的做法是按被试分别取中位数做阈值:
def binarize_by_subject(labels_subject): """labels_subject: 当前被试的标签,形状 (40, 4)""" th = np.median(labels_subject, axis=0) # 每类一个阈值 return (labels_subject >= th).astype(int)逻辑说明:中位数阈值保证了每个被试内部正负样本基本均衡,模型不用面对8比2这种极端分布。代价是语义从"绝对高低"变成"相对这个人而言的高低",但对情绪识别任务来说,个体相对变化本来就是更合理的目标,因为情绪的生理表现本来就因人而异。
4.3 基线模型怎么搭:SVM和随机森林的参数选择
特征有了、标签有了,接下来搭第一个能出结果的基线模型。常见做法是先跑两个:RBF 核 SVM 和随机森林,两者用 sklearn 的默认参数微调就能得到可用结果。
from sklearn.svm import SVC from sklearn.ensemble import RandomForestClassifier from sklearn.preprocessing import StandardScaler from sklearn.pipeline import make_pipeline svm = make_pipeline(StandardScaler(), SVC(kernel='rbf', C=1.0, gamma='scale')) rf = RandomForestClassifier(n_estimators=200, max_depth=8, random_state=42)参数说明:SVM 的 C 控制误分类惩罚,C 值太大会过拟合个体特异性模式,C=1.0 起步比较稳;gamma='scale' 让 sklearn 根据特征维度自动确定核宽,比手动设 gamma=0.1 之类的固定值可靠得多。随机森林的 n_estimators=200、max_depth=8,树太深在128维或160维特征上很容易记住单个被试的试次模式,深度限制在8左右泛化更好。
两个模型跑出来的准确率如果都落在65%到80%区间,说明特征提取和标签管线基本正常。如果有人报告90%以上的准确率,先别羡慕,去查他的数据划分是不是泄漏了,这是下一章的主题。
5. DEAP情感识别避坑指南:五个高频坑与排查方法
下面五条按出现频率排过序,前两条几乎每个第一次跑DEAP的人都会中招,后面三条属于"看起来对、细想不对"的隐蔽问题。每一条都按现象、原因、解决三步给,可以直接对照排查。
5.1 坑1:基线没扣干净,准确率虚高到不敢信
现象:某同学复现实验,直接对原始8064点提特征,SVM 二分类准确率上到88%,远超公开论文水平。后来把基线扣除加上,准确率掉到72%。
原因:原始信号里个体静息状态的绝对功率占主导,模型本质上在学"这个人是谁",而不是"这个人的情绪是什么"。被试和标签之间存在相关性,模型借这条捷径把准确率抬了上去。
解决:严格按3.3节的流程做基线扣除,并且对每个试次单独计算基线均值,绝不能把整段数据统一减一个全局均值。排查时可以做一个对照实验:把 labels 随机打乱再训练,如果准确率仍然明显高于50%,说明管线里存在依赖标签顺序的泄漏,特征或预处理一定有问题。
5.2 坑2:同一个被试的试次同时进训练和测试,跨被试直接翻车
现象:把所有试次按8比2随机划分,训练集里出现被试A的35个试次,测试集里出现被试A剩下的5个试次,测试准确率78%。换成"训练集只包含30个被试、测试集是被试A和B"的划分,准确率直接掉到55%。
原因:同一个被试的脑电模式高度相似,模型见过该被试的部分数据后,对剩余数据就作弊了。这是情感识别里最典型的数据泄漏形式,比特征泄漏隐蔽得多,因为从代码上你很难看出逻辑问题。
解决:以被试为分组单位划分数据,用 GroupKFold 或 LeaveOneGroupOut,而不是默认的 KFold。跨被试泛化才是这类任务的实际目标,被试内划分的结果只能说明模型记住了个体,不能说明它能识别情绪。我自己的习惯是实验一开始就用按被试划分,省得后面改评估逻辑时手忙脚乱。
5.3 坑3:标准化用了全量统计量,测试集信息提前泄漏
现象:先对全部样本的每个特征计算均值和方差,再用这个标准化器分别处理训练集和测试集,模型在测试集上表现异常好,但换一批新数据部署时效果大幅下滑。
原因:全局统计量里包含了测试集的信息,模型在训练时相当于间接看到了测试分布的均值和方差。这在 PSD 特征上尤其明显,因为不同被试的功率量级差异大,标准化统计量稍有偏差,特征分布就整体偏移。
解决:标准化器只能拟合训练集,测试集必须用训练集的均值和方法做转换。sklearn 里用 make_pipeline 串起 StandardScaler 和分类器,再套交叉验证,能天然避免这个坑,这也是4.3节坚持用 pipeline 的原因。排查方法很简单:把训练和测试分开标准化后,准确率如果明显下降,基本就是这里漏了。
5.4 坑4:标签阈值固定取5,类别不平衡导致指标失真
现象:某次实验用固定阈值5切唤醒度,正负样本比接近8比2,模型全部预测多数类也能有80%准确率,但 F1 只有0.4。
原因:DEAP 被试对唤醒度的打分普遍偏高,固定阈值5对打分习惯偏高的被试不公平,制造了天然的不平衡分布。
解决:改用4.2节的中位数阈值按被试切,或者把任务改成回归,直接预测1到9的连续分数。评估指标至少同时看准确率、F1、混淆矩阵三项,准确率单指标在类别不平衡时没有任何说服力。
5.5 坑5:只看准确率不看混淆矩阵,模型大概率在偷懒
现象:训练时监控准确率一路涨到85%,以为收敛良好。打印混淆矩阵发现模型把82%的样本都预测为效价低,剩下13%刚好预测对,准确率才显得高。
原因:标签不平衡时,准确率不是训练过程最直接的优化信号,模型学到"全猜多数类"这种偷懒解,在训练集上表现不差,在真实场景里毫无用处。
解决:每个实验结束都打印混淆矩阵和按类别的 F1。如果某一类的召回率明显偏低,先回查标签阈值和数据划分,再考虑类别加权参数,比如 SVC(class_weight='balanced') 或随机森林的 class_weight='balanced_subsample'。打印混淆矩阵是我在所有EEG实验里的固定自检步骤,花两分钟就能避免半天无效调试。
6. 从实验到实用:跨被试验证与滑动窗口在线推理技巧
前面所有流程都是按试次为单位做离线分类,但真实情感识别要面对的是连续信号流。最后补两个让实验更接近实用的技巧:跨被试评估和滑动窗口推理。
6.1 跨被试评估:LeaveOneGroupOut 才是论文级验证
from sklearn.model_selection import LeaveOneGroupOut from sklearn.metrics import accuracy_score, f1_score # 每个被试40个试次,共1280个样本,groups 标记每个样本属于哪个被试 groups = np.repeat(np.arange(32), 40) logo = LeaveOneGroupOut() accs, f1s = [], [] for train_idx, test_idx in logo.split(features, labels_bin, groups=groups): clf = make_pipeline(StandardScaler(), SVC(C=1.0, gamma='scale')) clf.fit(features[train_idx], labels_bin[train_idx]) pred = clf.predict(features[test_idx]) accs.append(accuracy_score(labels_bin[test_idx], pred)) f1s.append(f1_score(labels_bin[test_idx], pred)) print('LOSO 平均准确率:', np.mean(accs), 'F1:', np.mean(f1s))这段逻辑是每次留出一个被试的全部40个试次做测试,其余31个被试训练,重复32次。报出来的平均准确率和标准差,才是能写进报告、能跟论文对比的数字。随机划分的数字再好看,审稿人和面试官都会追问一句跨被试结果如何。
6.2 滑动窗口推理:把60秒切成2秒一片,在线识别才成立
线下实验用整段60秒提特征没问题,但真实场景里不可能等一段视频放完才给出情感判断。常见的做法是把试次切成2秒窗口、1秒步长,对每个窗口独立预测,最后投票得出试次级结论。
def sliding_windows(trial, win_len=2*128, hop=1*128): """trial: (40, 8064),返回 (窗口数, 40, win_len)""" n = trial.shape[1] windows = [] for start in range(0, n - win_len + 1, hop): windows.append(trial[:, start:start + win_len]) return np.stack(windows) # 使用示例:每个窗口提特征、预测,再把所有窗口的预测做多数投票 win_preds = [svm.predict(feature_from_window(w)) for w in sliding_windows(trial)] trial_pred = np.bincount(np.array(win_preds).astype(int)).argmax()参数说明:win_len=256个采样点,即2秒,hop=128个采样点,即1秒,60秒信号能切出59个窗口。窗口越短,实时性越好,但频率分辨率会变差,因为单窗FFT的频点变稀;窗口越长,特征越稳,但起步延迟越大。在线场景一般从2秒窗开始调,效果不行再往4秒加。
这里有个值得记住的 trade-off:Welch特征的 nperseg 不能超过窗口长度,如果窗口是2秒、256个点,nperseg 最多取256。所以在线系统的特征设计和离线系统往往是两套参数,不能直接复用。
我现在的习惯是,每接到一个EEG情感识别任务,先把数据管线按这套流程完整跑一遍:加载、缓存、扣基线、滤波、提特征、按被试交叉验证,最后打印混淆矩阵和F1。基线数字跑出来之后,再决定要不要上深度学习、要不要做多模态融合。这套流程帮我挡掉了大部分翻车现场,也希望帮到你。
本文还有配套的精品资源,点击获取