☰
DEAP数据集情感识别全流程解析:从数据读取到SVM建模避坑指南
2026/10/10 10:08:56 网站建设 项目流程

简介:一套基于DEAP数据库的MATLAB情感识别实现,面向生物信号处理与机器学习研究者,解决从多通道生理信号到情感分类的完整流程搭建与复现问题。压缩包共19个文件,以.m源码为主,涵盖数据加载、PSD特征提取、SVM分类、维度归约、电极映射与频带划分等环节,另有.asv自动备份、使用说明和版本管理文件;整体仅11KB,代码紧凑,便于逐行阅读和二次开发。已有2501人学习。实现方案模块化程度较高,从数据字典构建、特征结构化、频带特征提取到降维与分类器训练,脚本分工明确、步骤连贯,配套文档也提供了使用引导;体积虽小,却完整覆盖DEAP数据集从原始生理信号到情感标签映射的主要环节,并保留了修改痕迹与版本管理习惯,适合作为情感识别入门复现、课程设计延伸或算法对比实验的起点。

1. 用DEAP数据库做情感识别:先避开撞名坑,再谈跑通全流程

在模型训练圈里搜“DEAP”,很容易撞上一个同名的Python进化算法库;但在情感识别这条线上,DEAP指的是那套公开的生理信号情感数据库(DEAPdataset),采集了32名参与者观看40段音乐视频时的脑电与多项外周生理信号,每段结束还要对效价、唤醒、支配度、喜爱度做1~9打分。它常被拿来做情绪分类、回归,也能用于多模态特征分析。这篇博文围绕“使用DEAP数据集进行情感识别”这条主线,把数据读取、预处理、特征提取、SVM建模和常见坑一个个拆开讲。适合刚入手情感识别课题、准备用公开数据写实验的从业者,也适合想用Python把Matlab脚本重写一遍的开发者。

2. 数据读取与预处理:把 .mat 二进制变成能直接建模的特征矩阵

2.1 数据形态与脚本构成:先搞清楚手里这份资源是什么

先说结论:这份资源里装的是“分析脚本”,不包含DEAP原始数据文件。压缩包里是makeDEAPstruct.m、loadData.m、mainProcessDataScript.m、analyseSubject.m、ClassifySVM.m等一整套Matlab处理链路。数据需要你自己按DEAP官方渠道获取,获取到的是data_preprocessed_matlab这类目录,里面每个被试对应一个.mat文件。

DEAP预处理版本的数据维度要记牢:每个被试的.mat文件里,data变量是40×40×8064的三维数组。第一维40是试验次数(trial),每个trial对应一段约60秒的音乐视频,前面还附加了一段3秒的预试听基线;第二维40是通道数,前32通道是脑电,后8通道是外周信号;第三维8064是采样点,由63秒乘以128Hz采样率得到。同一份.mat里通常还有labels变量,维度是40×4,对应效价、唤醒、支配度、喜爱度,数值范围1~9。

这份资源里process2struct.asv、mainScript.asv这类后缀为.asv的文件是Matlab自动保存的备份文件,不是正式脚本,实际操作时直接忽略它们。正确的处理顺序是:先用loadData.m把某个被试的.mat读进来,再用makeDEAPstruct.m构建一个统一的数据结构,最后调用mainProcessDataScript.m做批量化预处理。

% 读取单个被试的数据,sXX.mat 是被试编号文件 subjectData = loadData('s01.mat'); % subjectData.data: 40(试验数) x 40(通道数) x 8064(采样点) % subjectData.labels: 40 x 4 (VA DL 四个维度) % 构建统一结构体 subjectStruct = makeDEAPstruct(subjectData);

这段代码里的loadData.m把.mat文件完整读入内存,makeDEAPstruct则是把data和labels封装成一个便于后续循环的struct。注意这里loadData返回的结构里data还是原始三维数组,没有做任何标准化。很多第一次接触DEAP的人在这里就急着提特征,结果基线噪声和幅度差异全部带进模型,后面再调SVM参数都救不回来。

2.2 预处理链路:标准化、基线校正与数据切片

DEAP的预处理版本虽然已经做过下采样和部分滤波,但每个被试的实际信号幅度差异很大,而且每段trial的前3秒是静息基线。常见的处理做法是对每个trial、每个通道分别减去前3秒的平均值,再做z-score标准化。

fs = 128; % 采样率 baselineSec = 3; % 前3秒为基线 baselinePts = fs * baselineSec; data = subjectStruct.data; % 40 x 40 x 8064 [numTrials, numChannels, ~] = size(data); % 基线校正:减去每个trial前3秒均值 dataBC = zeros(size(data)); for t = 1:numTrials for ch = 1:numChannels baselineMean = mean(data(t, ch, 1:baselinePts), 3); dataBC(t, ch, :) = data(t, ch, :) - baselineMean; end end % z-score 标准化:沿时间轴做,保留通道间的差异 dataStd = zeros(size(dataBC)); for t = 1:numTrials for ch = 1:numChannels dataStd(t, ch, :) = zscore(dataBC(t, ch, :)); end end

这里的baselinePts=384(128Hz×3秒),在循环里用mean取前384个点的平均值做减法。zscore是沿第三维时间轴标准化,所以你的归一化对象是同一通道的电压波动,而不是把所有通道拉到一个尺度。参数选型上,zscore比min-max更适合SVM,因为SVM依赖距离度量,z-score能把幅度差异控制在相近范围,避免某个通道幅值过大主导核函数计算。

如果要在Python里复刻这套逻辑,用scipy.io.loadmat读.mat文件即可。注意DEAP的预处理版本如果保存时用了Matlab v7.3格式,scipy是读不了的,会直接报错,这时要用h5py。先花一分钟检查变量名和维度,能省后面一晚上的排错时间。

import scipy.io as sio import numpy as np mat = sio.loadmat('s01.mat') data = mat['data'] # (40, 40, 8064) labels = mat['labels'] # (40, 4) # 转成 (trial, channel, sample),与 Matlab 维度顺序一致 data = np.asarray(data, dtype=np.float64) # 基线校正 baseline_pts = 3 * 128 data_bc = data.copy() for t in range(data.shape[0]): for ch in range(data.shape[1]): baseline_mean = np.mean(data[t, ch, :baseline_pts]) data_bc[t, ch, :] = data[t, ch, :] - baseline_mean # z-score 标准化 data_std = np.zeros_like(data_bc) for t in range(data.shape[0]): for ch in range(data.shape[1]): data_std[t, ch, :] = (data_bc[t, ch, :] - np.mean(data_bc[t, ch, :])) / np.std(data_bc[t, ch, :])

loadmat读出来的data默认是Matlab的列优先存储,转成numpy数组后直接用多层循环做预处理是最稳妥的方式。很多工程做法是尝试用np.apply_along_axis提速,但在这个场景下两层for循环的耗时完全可接受,而可读性高得多。真正容易出问题是labels变量写错字段名,有人把labels读成了label或者ratings,导致后面对齐错误。

3. PSD特征提取与维度削减:频带划分、电极映射与均值降维

3.1 频带划分与PSD计算:freqBands.m 到底做了什么

DEAP情感识别里用得最多的是频域特征,因为脑电不同节律与情绪状态有明显的相关性。这份资源里的freqBands.m就是把连续PSD谱切分成theta、alpha、beta、gamma几个频带。常见划分是theta(4~8Hz)、alpha(8~13Hz)、beta(13~31Hz)、gamma(31~45Hz),delta(0.5~4Hz)通常被丢掉,因为眼电和运动伪迹在低频段的干扰太大。

PSD的计算在extractFeaturesFromPSD.m里完成,用的是Welch方法。这里最关键的两个参数是窗长和重叠率。我用的是4秒窗长、50%重叠、nfft取值512,频率分辨率达到0.25Hz。窗长太短会导致频率分辨率劣化,频带边界切不准;窗太长又会把情绪变化过程中短时突变给平均掉。对于DEAP这种63秒的试次,4秒窗配合50%重叠既能保留情绪动态,又能让PSD估计足够平滑。

fs = 128; windowSec = 4; noverlapSec = 2; win = hamming(windowSec * fs); noverlap = noverlapSec * fs; nfft = 512; for trial = 1:40 for ch = 1:40 % 提取单个通道单次试验的信号 signal = squeeze(dataStd(trial, ch, :)); [psd, freqs] = pwelch(signal, win, noverlap, nfft, fs); % 取4~45Hz范围内PSD psd = psd(freqs >= 4 & freqs <= 45); % 按频带划分特征 thetaIdx = freqs >= 4 & freqs < 8; alphaIdx = freqs >= 8 & freqs < 13; betaIdx = freqs >= 13 & freqs < 31; gammaIdx = freqs >= 31 & freqs <= 45; featTheta(trial, ch) = mean(psd(thetaIdx)); featAlpha(trial, ch) = mean(psd(alphaIdx)); featBeta(trial, ch) = mean(psd(betaIdx)); featGamma(trial, ch) = mean(psd(gammaIdx)); end end

这段代码把每个频带的PSD均值作为该通道的特征值,最后得到40×40×4的特征张量,分别是trial数、通道数、频带数。pwelch里第一个参数是原始信号,hamming窗强制把截断效应压到可控范围,50%重叠保证每个采样点被重复利用。参数上需要留意的是nfft最好大于等于窗长,如果nfft=256而窗长=512,计算出来的频点数只有129个,频率分辨率变成0.5Hz,alpha和theta边界容易混。

3.2 特征结构体与维度削减:makeFeatureStruct 与 dimensionReductionAverage 的配合

特征算完之后,资源里的makeFeatureStruct.m负责把这些矩阵收纳成有批次信息的结构体,addFeaturesToSubject.m把多个trial的特征追加到被试对象上。这样组织的好处是后续ClassifySVM.m可以直接用一个特征矩阵和标签向量做训练,不需要再管40个trial分别是谁。

但40通道×4频带直接拼成160维特征向量喂给SVM,维度偏大且通道之间有冗余。资源里dimensionReductionAverage.m做的就是这种维度削减。常见的做法有两种:一种是直接把全脑40个通道的同一频带做平均,得到4个频带特征;另一种是按电极映射表分组,比如把额叶、顶叶、枕叶分开平均,保留一定的空间信息。

% 特征组织为 (trial, 40个通道*4个频带) 的矩阵 numTrials = size(featTheta, 1); featureMatrix = zeros(numTrials, 40 * 4); for t = 1:numTrials channelFeatures = [featTheta(t,:)'; featAlpha(t,:)'; featBeta(t,:)'; featGamma(t,:)']; featureMatrix(t, :) = channelFeatures(:)'; end % 简单按通道平均降维:把40个通道平均成4个频带均值 reducedFeat = zeros(numTrials, 4); reducedFeat(:,1) = mean(featTheta, 2); reducedFeat(:,2) = mean(featAlpha, 2); reducedFeat(:,3) = mean(featBeta, 2); reducedFeat(:,4) = mean(featGamma, 2);

dimensionReductionAverage.m的“平均”逻辑就落在这里。按通道平均看似粗暴,实际对DEAP这种被试间差异极大的数据反而友好:不同被试的脑电地形图差异很大,过度保留通道细节会让模型过拟合个别被试的电极摆放误差。如果你想保留空间信息,也可以把10-20系统的电极按额叶、中央区、顶叶、枕叶分成4组再平均,这个操作对应资源里的getElectrodeMapping.m。

% 电极分组映射示例: F, C, P, O 四个区域 frontalIdx = [1 2 3 4 5 6]; centralIdx = [7 8 9 10 11 12]; parietalIdx = [13 14 15 16 17 18]; occipitalIdx = [19 20 21 22 23 24]; featGroup = zeros(numTrials, 4 * 4); % 4个区域 * 4个频带 for t = 1:numTrials featGroup(t, 1:4) = [mean(featTheta(t, frontalIdx)), mean(featAlpha(t, frontalIdx)), ... mean(featBeta(t, frontalIdx)), mean(featGamma(t, frontalIdx))]; featGroup(t, 5:8) = [mean(featTheta(t, centralIdx)), mean(featAlpha(t, centralIdx)), ... mean(featBeta(t, centralIdx)), mean(featGamma(t, centralIdx))]; % 其余区域同理 end

getElectrodeMapping.m的作用就是提供通道编号到解剖分区的映射表。使用前先核对原始DEAP数据里的通道顺序,DEAP的32个EEG通道是按10-20系统标准排的,但如果你手里那份数据是别人重排过的,映射表就要重写。

3.3 Python 替代方案:用numpy手动算PSD特征

Matlab的pwelch在Python里可以用scipy.signal.welch对应,逻辑几乎完全一致。特征计算的核心逻辑不变,只是语法换成Python。很多时候你在DEAP相关项目里看到python实现的情感识别代码,走的也是这条线。

from scipy.signal import welch import numpy as np fs = 128 window_len = 4 * fs noverlap = 2 * fs nfft = 512 feat_theta = np.zeros((num_trials, num_channels)) feat_alpha = np.zeros((num_trials, num_channels)) feat_beta = np.zeros((num_trials, num_channels)) feat_gamma = np.zeros((num_trials, num_channels)) for trial in range(num_trials): for ch in range(num_channels): signal = data_std[trial, ch, :] freqs, psd = welch(signal, fs=fs, nperseg=window_len, noverlap=noverlap, nfft=nfft) freq_mask = (freqs >= 4) & (freqs <= 45) freqs = freqs[freq_mask] psd = psd[freq_mask] feat_theta[trial, ch] = np.mean(psd[(freqs >= 4) & (freqs < 8)]) feat_alpha[trial, ch] = np.mean(psd[(freqs >= 8) & (freqs < 13)]) feat_beta[trial, ch] = np.mean(psd[(freqs >= 13) & (freqs < 31)]) feat_gamma[trial, ch] = np.mean(psd[(freqs >= 31) & (freqs <= 45)])

scipy.signal.welch的nperseg对应Matlab的window长度,noverlap要显式给出。这里有个容易忽略的坑:welch返回的freqs数组长度跟nfft有关,但你的频带索引必须写在freqs经过freq_mask截断之后,否则索引位置错位。nperseg设置为512,nfft保持512,频率分辨率就是128/512=0.25Hz,和Matlab版本完全对齐。

4. SVM建模与评估:从sigmoid标签到交叉验证的关键设置

4.1 标签获取与二分类构造:getLabels.m 到底在返回什么

DEAP的标签是1~9的主观评分,不是直接的类别标签。getLabels.m的作用是从labels变量里提取效价、唤醒等维度评分。情感识别做分类时,大家一般会把评分从中间某个阈值切成两类。常见做法是valence分大于4.5切成“正效价/负效价”,arousal大于4.5切成“高唤醒/低唤醒”。4.5这个阈值有争议,因为1~9的量表没有明确的“正中间”定义,但大多数公开实验都取4.5或5。也有团队的方案是干脆当回归做,不切二分类,保留1~9连续值。如果你做分类效果不理想,试试改用回归预测评分,再用区间映射到情绪类别。

% 从subjectStruct.labels提取效价和唤醒评分 valenceLabels = subjectStruct.labels(:, 1); % 1~9 arousalLabels = subjectStruct.labels(:, 2); % 1~9 % 二分类切分 valenceClass = double(valenceLabels > 4.5); arousalClass = double(arousalLabels > 4.5);

这里要特别注意:double转换后类别是0/1,SVM的标签数组必须是整数或者逻辑值。如果你直接把1~9的原始评分丢给fitcsvm去做分类,SVM会把它当成9个离散类别,类别分布极其不均,模型基本学不到有效边界。先value_counts统计一下各类别数量,如果发现某个类别只有几个样本,就需要增大阈值范围或者用smote之类的过采样手段。

4.2 交叉验证与模型评估:单个被试SVM的得分怎么看

ClassifySVM.m的核心流程是:特征矩阵×(trial数),标签向量×(trial数),做交叉验证。因为DEAP每个被试只有40个trial,样本量很小,最怕随机划分造成训练集和测试集分布不一致。更危险的是在特征提取阶段就混入了所有trial的信息,比如用全体trial的均值做标准化,那评估出来的准确率是虚高的。

% 以单个被试的 valence 二分类为例 X = featureMatrix; % 40 x 160 (或40 x 4) y = valenceClass; % 40 x 1 % 5折交叉验证 rng(42); cv = cvpartition(y, 'KFold', 5); svmModel = fitcsvm(X, y, 'Standardize', true, 'KernelFunction', 'rbf', ... 'BoxConstraint', 1, 'KFold', 5); accuracy = 1 - kfoldLoss(svmModel, 'LossFun', 'ClassifError');

fitcsvm里Standardize设为true,就是把特征在SVM内部再做一次标准化。如果你在预处理阶段已经做过z-score,这里的Standardize可以设为false,但实操中多一层标准化对RBF核影响不大;BoxConstraint对应SVM的C值,C越小对误分类的惩罚越弱,模型越平滑。40个样本做5折,每折测试集只有8个样本,准确率的方差很大。理想的做法是重复多次5折交叉验证,每次换随机种子,取平均结果。

from sklearn.svm import SVC from sklearn.model_selection import StratifiedKFold, cross_val_score from sklearn.preprocessing import StandardScaler X = feature_matrix # (40, 160) y = valence_class # (40,) scaler = StandardScaler() X_scaled = scaler.fit_transform(X) svm = SVC(kernel='rbf', C=1.0, gamma='scale', class_weight='balanced') cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42) scores = cross_val_score(svm, X_scaled, y, cv=cv, scoring='accuracy') print(f'Accuracy: {scores.mean():.3f} ± {scores.std():.3f}')

SVC的gamma设置成scale会根据特征数量自动计算,一般比你手动调参靠谱。class_weight='balanced'是个容易被人忽略的参数,DEAP的标签分布虽然理论上1~9都比较常见,但切到4.5阈值后,正负样本比例不一定均衡,加了这个参数后SVM的决策边界会自动向少数类倾斜。

5. 避坑与排查:五处最容易翻车的地方及解决记录

5.1 文件读取与asv备份文件混淆

现象:按文件名逐个找主脚本,打开mainScript.asv修改并运行,发现改动完全不起作用,模型结果和改之前一模一样。

原因:asv是Matlab自动保存的备份文件,不是可执行脚本。Matlab在编辑.m文件时会生成同名.asv文件,但asv不参与运行。很多人分不清mainProcessDataScript.m和mainScript.asv哪个是正式入口,改了半天改的是备份文件。

解决:先在Matlab编辑器里用“显示文件完整路径”确认后缀。asv文件可以直接删除,不影响项目运行。或者用gitignore把*.asv过滤掉。运行入口认准mainProcessDataScript.m和analyseSubject.m。

5.2 数据读入时维度错位或变量名对不上

现象:用scipy.io.loadmat读取.mat,得到的data维度不是40×40×8064,而是(1, 40, 40, 8064)或者完全读不出来。

原因:DEAP的.mat文件如果由Matlab 7.3版本保存,实际上是HDF5格式,需要用h5py读取,scipy.io.loadmat不支持这种版本。另一个常见原因是变量名在不同版本之间并不统一,有的叫data,有的叫data_preprocessed,有的叫EEG。

解决:读文件前先打一行测试代码把.mat里的文件头结构打印出来。如果是HDF5格式,直接用h5py.File读取,然后通过np.array完成维度转换。变量名先print出来再取,比盲猜字段安全得多。

import h5py with h5py.File('s01.mat', 'r') as f: print(list(f.keys()))

5.3 PSD计算里的频率分辨率“玄学”与基线段混入

现象:算出来的theta和alpha特征差别不明显,SVM准确率接近随机;或者同一个特征在不同trial之间的数值抖动得厉害。

原因:Welch窗长太短,默认nfft只有256,频率分辨率粗到1Hz,theta和alpha边界处信息被平均;也可能是没有做基线校正,前3秒静息状态的噪声直接混入特征。

解决:窗长强制设为4秒×采样率,nfft至少等于窗长,重叠率设为50%。预处理时一定要减去前3秒基线均值,否则静息状态下眼电伪迹和alpha节律会污染频带能量。

5.4 标签二分类构造翻车:预测结果全是同一类

现象:SVM训练后,测试集预测结果全为0或全为1,准确率等于基线比例。

原因:把1~9的评分直接当成二分类阈值,但没有做数值比较,而是把评分本身当成类别;或者类别不均衡严重,SVM在RBF核下倾向于把所有样本都分到多数类。

解决:先检查标签向量里0/1的数量比例。如果某一类样本少于总数20%,要么改阈值,要么用class_weight='balanced'加权。同时用分类报告的confusion matrix确认是不是全部走进了多数类。

5.5 跨被试划分导致数据泄漏:准确率90%的假象

现象:把32个被试的所有trial混在一起做随机5折交叉验证,准确率高达85%以上,但单独做被试内验证只有60%出头。

原因:同一被试的多个trial可能同时出现在训练集和测试集,模型学到了被试固有的生理信号特征,而不是情绪相关的特征。这在脑电情感识别里是最大的坑。脑电信号跟被试的个体差异高度相关,甚至能够直接通过信号模式认出是谁。

解决:训练和测试数据必须按被试分组划分。常见做法是留一个被试出来做测试,其余31个被试的trial做训练,这叫留一被试交叉验证(Leave-One-Subject-Out)。评估指标看平均准确率,而不是混合交叉验证的分数。

6. 进阶验证:让情感识别模型从“能跑”走向“可信”

前面章节把单个被试的流程跑通了,但一个模型如果只在某个人身上有效,还不能说明它在DEAP上真的成立。进阶用法是把验证协议从“被试内随机划分”换成“跨被试评估”,这是情感识别发论文和业务落地的分水岭。

常见做法是对32个被试做留一被试交叉验证:每次拿31个被试的全部trial训练SVM,留1个被试的40个trial做测试,循环32轮。最后把32个被试的预测结果拼到一起,计算总准确率、宏平均F1、AUC。这样评估得到的指标反映的是模型对新用户的泛化能力,也接近真实场景中“预先训练模型、再做推理”的部署方式。

subjects = range(1, 33) y_true_all = [] y_pred_all = [] for test_sub in subjects: X_train_list, y_train_list = [], [] for train_sub in subjects: if train_sub == test_sub: continue # 读取每个被试的特征与标签 X_train_list.append(features[train_sub]) y_train_list.append(labels[train_sub]) X_train = np.vstack(X_train_list) y_train = np.concatenate(y_train_list) X_test = features[test_sub] y_test = labels[test_sub] scaler = StandardScaler().fit(X_train) X_train = scaler.transform(X_train) X_test = scaler.transform(X_test) clf = SVC(kernel='rbf', C=1.0, gamma='scale', class_weight='balanced') clf.fit(X_train, y_train) y_pred = clf.predict(X_test) y_true_all.extend(y_test) y_pred_all.extend(y_pred)

代码里每次循环只留一个被试做测试,训练集和测试集严格按被试分离。StandardScaler必须在训练集上fit,再用同一套均值和方差去transform测试集,禁止在全体数据上偷看测试集的信息。这里gamma=scale会自动根据维度数量调整核函数尺度,对于160维左右的输入特征是一个安全的起点。

跑完32轮之后打印混淆矩阵,通常会看到valence分类准确率在60%~70%之间,arousal分类可能会略高,这是DEAP上的正常水平。如果有人拿着混合划分跑出来的90%准确率当结论,基本可以判断是数据泄漏了。我也曾经在模拟项目X里踩过这个坑,当时天真地把所有trial混在一起交叉验证,结果单被试验证直接掉了20个点。

从那以后,我每次跑DEAP都会强制走一遍留一被试交叉验证,无论结果好不好看,至少这个数字能放在明面上讨论。这个习惯帮我避免了好几次论文返工。如果你手头正好有DEAP数据,建议拿到手先跑通单被试流程,再做跨被试验证,最后再尝试把脑电和外周信号特征融合起来看有没有提升。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询