简介面向情感计算与脑机接口方向学习者的英文研究论文聚焦基于脑电信号的情绪检测任务适合做毕业设计、课程研究或算法复现的同学参考。文中梳理了使用公开数据集开展 EEG 情绪识别的相关工作并提出一套可落地的监督学习流程对 DEAP 与 SEED-IV 的脑电信号做离散小波变换划分五个频段后提取功率、能量、微分熵及时域特征再以通道级 SVM 分类器配合通道组合策略判定二维模型中的情绪状态并给出两个数据库四类分类的具体结果。整份资料为单个 PDF 文件压缩包约 701KB篇幅紧凑可直接作为文献精读与实验设计的依据。目前已有 347 人学习读者可从中获得数据集选择、频段划分、特征工程与分类器设计的完整思路也能对照论文给出的准确率指标检验自己的复现实验理解通道组合对识别效果的影响。1. 从一份双库基线说起EEG 情绪检测为什么值得照着重跑一遍做 EEG 情绪检测的毕业设计十有八九绕不开两个公开数据库DEAP 和 SEED-IV。前者 32 名被试、32 导联后者 64 导联、15 名被试各做满 3 个 session。而被要求复现的模型往往是支持向量机——训练快、小样本也能收敛、超参就那么几个。真动手时才会发现能搜到的资料大多只留下结论四分类准确率 74%、86%、72%、84%。中间的通道怎么对齐、标签怎么从效价和唤醒度切成四类、8 层 db8 小波在降采样数据上还能不能切出 31–60 Hz全都没有交代。这次的拆解就是把这些中间步骤摊开从数据落盘一路写到通道融合给出可以照着敲的代码适合手里已经有数据库、但卡在预处理和特征环节的人。2. 数据落盘与事件切分DEAP 的 .dat 和 SEED-IV 的 .mat 差在哪两份数据放在同一个项目里用第一件要认清的事实是它们的组织方式、标签含义、采样率、通道数没有一项是统一的。很多人第一步就栽在把两个库当成“同构数据”直接拼起来。2.1 先把 shape 打印出来DEAP 的 pickle 字典结构DEAP 常见的预处理版本是“每个被试一个文件”文件名形如s01.dat内容是 Python pickle 序列化的字典。这个字典的键至少包含data和labels其中data是三维数组形状为 (试次数, 通道数, 采样点数)前 32 个通道是 EEG后面几个是外周生理信号labels是 (试次数, 4)四列分别是效价、唤醒度、支配度和喜好度取值 1–9。import pickle import numpy as np SUBJECT_FILES [fs{i:02d}.dat for i in range(1, 33)] def load_deap(path): # 老版本 pickle 用 latin1 才能读出 numpy 对象不加这个参数会直接抛 UnicodeDecodeError with open(path, rb) as f: subj pickle.load(f, encodinglatin1) eeg subj[data][:, :32, :] # 只保留前 32 导 EEG切掉外周通道 labels subj[labels] # (40, 4) return eeg.astype(np.float32), labels.astype(np.float32) eeg, lab load_deap(SUBJECT_FILES[0]) print(eeg.shape, lab.shape) # 期望 (40, 32, 8064) 与 (40, 4) print(eeg.min(), eeg.max()) # 微伏量级注意后续是否要乘 1e-6这段代码做了三件事用encodinglatin1兼容旧 pickle用切片[:, :32, :]把外周通道剔除避免后面特征维度莫名其妙多出几列统一转float32因为后面 32 个分类器要反复复制特征矩阵float64会让内存翻倍。8064这个长度来自“60 秒视频 3 秒基线”在 128 Hz 下的采样点数你在自己那份数据上如果打出来不是这个数先别改代码先确认拿到的是原始 500 Hz 版本还是重采样后的版本两者的预处理参数完全不同。一个容易被忽略的点是labels的列顺序在不同来源的包里可能有差异动手前拿几个样本对照一下自评量表的取值范围1–9确认哪一列是效价、哪一列是唤醒度。切错列不会报错只会让四分类的准确率稳定地卡在 25% 附近这种“看起来在跑、实际上在乱分类”的情况最难查。2.2 SEED-IV 的 cell 结构与 squeeze 陷阱SEED-IV 常见组织方式是“按 session 分目录每个 session 一套 EEG 与标签文件”EEG 文件里存的是一个 cell 数组每个 cell 对应一次试验形状为 (通道数, 采样点数)。因为是 cell各次试验长度不一定相同不能直接np.stack。import scipy.io as sio import numpy as np def load_seed4(path): # squeeze_me 会把 1×1 的 cell 压成标量这里必须防一手 m sio.loadmat(path, squeeze_meTrue, struct_as_recordFalse) trials m[EEG_data] if not isinstance(trials, np.ndarray) or trials.dtype ! object: trials np.array([trials], dtypeobject) return [np.asarray(t, dtypenp.float32) for t in trials] trials load_seed4(session1/eeg_data.mat) print(len(trials), trials[0].shape)squeeze_meTrue是个双刃剑它能把(1, 24)的 cell 压成一维对象数组写起来顺手但当文件里只有一次试验时它会进一步压成标量后面len()直接就崩。加一层isinstance判断是成本最低的保险。另外struct_as_recordFalse是为了让嵌套结构以对象属性访问读标签时比层层下标清晰。拿到 cell 之后长度不齐的问题必须显式处理。我的习惯是取所有试次长度的最小值统一截断而不是补零——补零会在频域特征里制造一个巨大的阶跃微分熵和带功率都会被污染。截断会丢一点数据但换来的是特征可比。2.3 四分类标签两个库的“四类”根本不是一回事这是整份材料里最需要说清楚的一点。DEAP 走的是二维情感模型效价和唤醒度各按 5 分阈值切高低组合出四个象限高唤醒高效价、高唤醒低效价、低唤醒低效价、低唤醒高效价。SEED-IV 走的是离散情绪标签本身就是四个类别。两者都被叫做“四分类”但语义完全不同跨库比较准确率是没有意义的。def to_quadrant(labels, thr5.0, v_idx0, a_idx1): v_high labels[:, v_idx] thr a_high labels[:, a_idx] thr code np.zeros(len(labels), dtypeint) code[~v_high a_high] 1 # 高唤醒低效价 code[~v_high ~a_high] 2 # 低唤醒低效价 code[v_high ~a_high] 3 # 低唤醒高效价 return code # 0 为高唤醒高效价阈值取 5 是 DEAP 自评量表的中位线也是最省事的做法。更稳的做法是按被试内中位数切分这样能抵消个体打分习惯的偏差——有人习惯全打 7 分以上固定阈值会让他四类里只剩两类。代价是每个被试的类别边界不同跨被试合并训练时要格外小心。v_idx和a_idx做成参数是因为前面提过列顺序可能不一致写死下标迟早翻车。2.4 通道对齐32 导和 64 导怎么凑到一起材料里提到“两个库使用相同的通道”实际落地时必须自己完成这个对齐。DEAP 的 32 导是 10-20 系统的一个子集SEED-IV 是 64 导的全覆盖布局交集大约在 20 多导的量级。两条路可选一是取交集导联两个库都裁到共同通道上好处是特征语义一致二是各自独立训练独立评估只在结论层面比较材料里的做法更接近这一种。处理方式优点代价适用场景取导联交集特征列可直接对比丢掉两侧独有导联DEAP 只剩二十几导想做跨库迁移实验各自独立建模保留各自全部信息两套模型、两套超参不能直接比复现单库基线上采样到 64 导维度统一插值引入虚假空间信息不推荐如果选交集务必在代码里写死一张映射表而不是靠字符串模糊匹配。Fp1和FP1大小写不一致、T7与T3属于同一位置的旧命名这类问题不会报错只会让某个通道静默地变成全零。采样率同理。DEAP 预处理版常见是 128 HzSEED-IV 原始采集是 1000 Hz。降到同一采样率时先低通再抽取别直接隔点取。from scipy.signal import resample_poly # 1000 Hz - 128 Hz用多项式重采样16/125 是有理数近似 x_ds resample_poly(x, up16, down125, axis-1)up/down必须约到最简整数比16/125正好等于128/1000。直接写resample(x, 128)用的是 FFT 重采样对长序列会引入周期性边界效应resample_poly内部带抗混叠滤波更适合 EEG 这种连续信号。降采样之前最好再做一次 60 Hz 以内的低通否则 128 Hz 下 64 Hz 以上的成分会折叠回来混进 gamma 频段而你完全看不出来。3. 预处理四道关口50 Hz 陷波、平均重参考、中值滤波与 0.1–60 Hz 带通的参数取值预处理顺序不是随便排的。陷波要在最前面因为工频干扰是窄带强信号先滤掉它后面的中值滤波和带通才不会被它带偏重参考放在陷波之后、滤波之前因为重参考是一次线性组合会改变各通道的相位关系中值滤波针对脉冲伪迹带通放最后把整段信号约束在 EEG 的有效频段内。3.1 50 Hz 陷波Q 值决定你削掉多宽工频选择看供电环境50 Hz 和 60 Hz 各对应一套参数。材料里用的是 50 Hz这在 220 V 供电环境下是常规选择。关键是 Q 值Q 越高阻带越窄对邻近频点的损伤越小但对频率漂移越敏感。from scipy.signal import iirnotch, filtfilt import numpy as np def notch_50(x, fs, q30.0): b, a iirnotch(w050.0, Qq, fsfs) return filtfilt(b, a, x, axis-1) eeg_notched notch_50(eeg, fs128.0, q30.0) print(np.abs(np.fft.rfft(eeg_notched[0, 0]))[48:53]) # 看 48-52 Hz 附近的残余Q30对应带宽约50/30 ≈ 1.67 Hz也就是 49.2–50.8 Hz 被压下去对相邻的 gamma 低端影响很小。Q 调到 60 以上带宽不到 1 Hz一旦采集设备的实际工频漂到 50.2 Hz陷波就削不准Q 调到 10带宽 5 Hz会把 47–53 Hz 整段 gamma 一起吃掉。我一般从 30 起步用上面那行 FFT 打印确认残余峰值低于噪声底就算过。用filtfilt而不是lfilter是为了零相位。EEG 的很多特征依赖波形的时间结构单向滤波会引入群延迟不同频率延迟不同等于人为改变了信号的形态。3.2 平均重参考一次减法改变所有通道多数公开数据是以某个固定电极或平均参考录制的。转到平均参考就是每个时间点减去当刻所有通道的均值。这一步看着简单但对分类结果影响不小因为它把“共同成分”从每个通道里抽走了。def average_reference(x): # x: (n_channels, n_samples) return x - x.mean(axis0, keepdimsTrue) eeg_car average_reference(eeg_notched[0]) print(np.abs(eeg_car.sum(axis0)).max()) # 理论上应为 0浮点误差内axis0是对通道维求均值keepdimsTrue保住广播维度。最后那行检查很实用重参考后每个时间点上所有通道之和应当是 0如果打出来是几百微伏说明轴选错了或者数据里混进了非 EEG 通道。要注意的是重参考会改变特征的可比性。如果 DEAP 已经是平均参考、SEED-IV 是 Cz 参考两边都做一次平均重参考之后才可比但如果其中一份已经是平均参考再做一次只是重复减法结果不变不会出错。麻烦在于你不知道原始参考是什么——这时候看数据文档里有没有ref字段没有就问数据提供方别猜。3.3 10 阶中值滤波偶数窗是个隐藏的坑材料里写的是“1 维 10 阶中值滤波”对应 MATLAB 的medfilt。问题在于标准中值滤波要求窗口长度为奇数偶数窗在实现上必须偏向一侧取样不同库的偏向规则还不一样。from scipy.signal import medfilt # 用 11 点窗代替 10 点128 Hz 下约 86 ms足够压制脉冲又不会把波形抹平 eeg_med medfilt(eeg_car, kernel_size11)kernel_size11在 128 Hz 下覆盖约 86 ms。这个长度是我试出来的平衡点小于 7 点眨眼和肌电的尖峰压不干净大于 15 点alpha 段的纺锤波会被削掉一截后面算出来的带功率偏低。更重要的一个判断是中值滤波到底该放在降采样前还是后。放在 128 Hz 上做窗口 11 点已经是 86 ms对 30 Hz 以上的成分有可见衰减如果原始数据是 500 Hz先在 500 Hz 上做 43 点窗同样 86 ms再降采样效果等价但计算量大四倍。真正需要警惕的是别在 128 Hz 上做 21 点以上的窗那已经开始削 beta 了。3.4 0.1–60 Hz 带通20 阶滤波器为什么必须用二阶节材料里给的参数是 20 阶、0.1–60 Hz用 MATLAB 的filtfilt实现。在 Python 侧直接照搬会踩两个坑一是高阶传递函数在数值上极度不稳定二是filtfilt的默认填充长度会直接抛异常。from scipy.signal import butter, sosfiltfilt def bandpass(x, fs, lo0.1, hi60.0, order20): # 用 SOS 形式避免高阶 ba 形式的数值病态 sos butter(order, [lo, hi], btypebandpass, fsfs, outputsos) # padlen 手动指定否则默认 3*(2*order1) 会超过序列长度 padlen min(x.shape[-1] - 1, 3 * (2 * order 1)) return sosfiltfilt(sos, x, axis-1, padlenpadlen) eeg_bp bandpass(eeg_med, fs128.0)outputsos是关键。20 阶带通转成(b, a)之后极点分布极其接近单位圆float64都压不住误差滤波结果可能出现发散或数值溢出拆成多个二阶节串联就稳得多。padlen那行是血泪经验sosfiltfilt默认按滤波器阶数估算填充长度20 阶带通算出来是 123 点如果你的试次只有 100 点比如截断得比较狠直接报ValueError。手动把padlen压到序列长度以内代价是边缘几帧的相位补偿不完美但总比跑不起来强。还有一个隐性约束60 Hz 的上限必须小于奈奎斯特频率。128 Hz 采样下奈奎斯特是 64 Hz60 Hz 已经贴着边了实际滤波器的过渡带会延伸到 64 Hz 附近滚降不干净。这也是为什么材料里虽然写了 31–60 Hz 的 gamma 频段实际能拿到的有效 gamma 只是 31–58 Hz 这一小段。3.5 MATLAB 与 Python 的对应关系环节MATLAB 写法Python 对应注意点陷波iirnotch/filtfiltscipy.signal.iirnotchfiltfiltQ 值语义一致中值medfilt(x, 10)scipy.signal.medfilt(x, 11)偶数窗必须改奇数带通filtfilt(b, a, x)butter(..., outputsos)sosfiltfilt高阶必须用 SOS重参考手动减均值手动减均值 /mne.set_eeg_reference先确认原始参考如果团队里有人习惯用 MNE也可以整条链路走 MNE通道名和 montage 的管理会省事很多import mne info mne.create_info(ch_names, sfreq128.0, ch_typeseeg) raw mne.io.RawArray(eeg_bp * 1e-6, info, verboseFalse) # 转成伏特 raw.set_montage(standard_1020, on_missingwarn) raw.notch_filter(50.0, pickseeg, verboseFalse) raw.set_eeg_reference(average, verboseFalse) # MNE 的 filter 默认是 FIR想复刻论文里的 IIR 必须显式指定 raw.filter(0.1, 60.0, methodiir, iir_paramsdict(order4, ftypebutter), verboseFalse)这里order4和前面order20不矛盾SOS 形式下 20 阶带通拆成 10 个二阶节MNE 的iir_params里的order指的是单个节的阶数实际级联效果需要自己对照频响曲线确认。* 1e-6是因为 MNE 内部统一用伏特不转的话通道幅值会是几百万某些绘图和伪迹检测函数会给出离谱结果。4. 从 34 维特征到 32 个 SVM小波频带、熵特征与通道级分类器的拼接特征这一章是整份材料最“看起来清楚、实际最模糊”的部分。表格列了三大类特征但没给展开式说了 8 层 db8 分解出 5 个频段但没给采样率和层数的对应关系。这一节把这两处补上。4.1 8 层 db8 与五个频段先承认它对不上离散小波每一层把当前频带二等分。在 128 Hz 采样下第 1 层细节系数对应 32–64 Hz第 2 层 16–32 Hz依次往下。目标频段是 delta 1–3、theta 4–7、alpha 8–13、beta 14–30、gamma 31–60。把两组边界摆在一起就会发现它们没有一条是重合的。分解层细节系数频带128 Hz最接近的目标频段偏差132–64 Hzgamma 31–60上限被奈奎斯特截断216–32 Hzbeta 14–30两端各溢出 2 Hz38–16 Hzalpha 8–13上限多出 3 Hz混入低 beta44–8 Hztheta 4–7上限混入 alpha 低端52–4 Hzdelta 1–3上限混入 theta 低端61–2 Hzdelta 低端与层 5 重叠这张表说明一件事材料里的“5 个频段”是一种工程近似把层 2–5 直接映射成 beta、alpha、theta、delta层 1 当 gamma 用。这么做能跑但特征语义是漂的。我在自己项目里会同时保留两套特征做对照一套是 DWT 近似映射一套是带通滤波后算带功率两者差距在 3 个百分点以内才敢用前者因为前者快得多。import pywt import numpy as np LAYER_TO_BAND {1: gamma, 2: beta, 3: alpha, 4: theta, 5: delta} def dwt_band_energy(x, waveletdb8, level8): # coeffs[0] 是近似系数 a8coeffs[1..8] 依次是 d8..d1 coeffs pywt.wavedec(x, waveletwavelet, levellevel, modesymmetric) out {} for layer, band in LAYER_TO_BAND.items(): d coeffs[level - layer 1] # d1 在末尾索引要倒过来 out[band] np.sum(d ** 2, axis-1) # 细节系数平方和近似该层能量 return outmodesymmetric是边界延拓方式对 8064 点的长序列影响可以忽略但对截断后的短试次影响明显建议统一用对称延拓而不是零填充。coeffs的索引顺序是最容易写反的地方wavedec返回的是从粗到细coeffs[0]是最低频的近似coeffs[-1]才是最高频的 d1所以取第layer层细节要用level - layer 1。带通版本作为对照from scipy.signal import butter, sosfiltfilt BANDS {delta: (1, 3), theta: (4, 7), alpha: (8, 13), beta: (14, 30), gamma: (31, 58)} def bandpass_split(x, fs128.0, order4): nyq fs / 2.0 out {} for name, (lo, hi) in BANDS.items(): sos butter(order, [lo / nyq, min(hi, nyq * 0.95) / nyq], btypebandpass, outputsos) out[name] sosfiltfilt(sos, x, axis-1) return outgamma 的上限我写成 58 而不是 60是因为 0.95 × 64 60.8取 58 能让滤波器在奈奎斯特之前有足够的滚降空间避免通带边缘的增益畸变。4.2 34 维特征是怎么凑出来的材料给了一张特征表但没给总数。把表里的项逐条数一遍时域 12 项均值、均方根、标准差、一阶差分、归一化一阶差分、二阶差分、归一化二阶差分、峰度、偏度、方差、Hjorth 活动度、Hjorth 复杂度频域 5 频段 × 4 项带能量、功率谱密度、微分熵、平均带功率共 20 项再加 Hurst 指数和排列熵 2 项合计 34。这个数正好对上材料里 880×34 和 742×34 的第二维算是能自洽的拆法。def hjorth(x): d1 np.diff(x, axis-1) d2 np.diff(d1, axis-1) var0 np.var(x, axis-1) var1 np.var(d1, axis-1) mobility np.sqrt(var1 / (var0 1e-12)) complexity np.sqrt(np.var(d2, axis-1) / (var1 1e-12)) / (mobility 1e-12) return mobility, complexity def diff_entropy(x): # 高斯假设下微分熵的闭式解单位 bit var np.var(x, axis-1, ddof1) return 0.5 * np.log2(2 * np.pi * np.e * (var 1e-12)) def band_power_psd(x, fs, nperseg256): from scipy.signal import welch f, pxx welch(x, fsfs, npersegmin(nperseg, x.shape[-1]), axis-1) return np.trapz(pxx, f, axis-1)1e-12这类小量不是凑数是为了防止某段信号方差恰好为零比如坏道全是常数时出现inf或nan一旦特征矩阵里出现nanSVM 会直接抛异常而报错信息只告诉你“输入包含 NaN”不告诉你哪一行哪一列。Hurst 指数和排列熵计算量大尤其是排列熵对每个通道每个试次都要跑一次。我这里给一个可用的排列熵实现注意阶数不要超过 5否则模式数量按阶乘增长24 个通道跑完要几分钟。from math import factorial def perm_entropy(x, order3, delay1, normalizeTrue): n len(x) - (order - 1) * delay if n 0: return 0.0 idx np.arange(order) * delay np.arange(n)[:, None] patterns np.argsort(x[idx], axis1) _, counts np.unique(patterns, axis0, return_countsTrue) p counts / counts.sum() pe -np.sum(p * np.log2(p)) return pe / np.log2(factorial(order)) if normalize else peorder3时模式只有 6 种统计量本身噪声不小但作为辅助特征够用delay1在 128 Hz 下对应 7.8 ms对 EEG 这种强自相关信号延迟取 1 会让排列熵整体偏低这是方法本身的特性不是 bug。4.3 通道级特征矩阵880×34 和 742×34 怎么来的材料给的两个数字需要拆开看。DEAP 侧是 22 名被试 × 40 次试验 880 行每行 34 个特征这是训练集剩下 10 名被试做测试。SEED-IV 侧是 32 名被试参与训练得到 742 行。但 32 × 24 768和 742 差 26 行。这个差值大概率来自短试次被丢弃或个别文件缺事件属于正常的工程损耗不必强行凑成 768。真正要警惕的是如果你自己拼出来是 768 行而论文写 742别怀疑代码写错了先去看丢弃规则是否一致。def build_channel_matrix(trials, band_feats, feat_fn): trials: list of (n_ch, n_t)返回 (n_events, n_ch, n_feat) rows [] for x in trials: per_ch [] for ch in range(x.shape[0]): sig x[ch] feats [feat_fn(sig)] # 时域 12 项 熵 2 项 for band, sig_b in band_feats.items(): feats.append(band_energy(sig_b[ch])) per_ch.append(np.concatenate(feats)) rows.append(np.stack(per_ch)) # (n_ch, 34) return np.stack(rows) # (n_events, n_ch, 34)注意这里的维度顺序(事件, 通道, 特征)。后面训练通道级分类器时要按通道切片成(n_events, 34)而不是按事件切片。这一步搞反是最常见的维度错误而且sklearn不会报错它会默默把你的 32 个通道当成 32 个样本训练最后准确率看着还行其实完全不是通道级模型。4.4 32 个 SVM划分方式比超参更重要材料里说 32 个通道各训练一个 SVMDEAP 用 22 人训练、10 人测试SEED-IV 用 32 人训练、13 人测试。这个划分方式是按被试分的不是随机分样本这一点必须严格遵守。from sklearn.svm import SVC from sklearn.preprocessing import StandardScaler from sklearn.pipeline import make_pipeline from sklearn.model_selection import GridSearchCV, GroupKFold def train_channel_svm(X, y, groups): # X: (n_events, 34) 单通道特征groups: 被试编号 pipe make_pipeline( StandardScaler(), SVC(kernelrbf, probabilityTrue, class_weightbalanced) ) grid {svc__C: [0.1, 1, 10, 100], svc__gamma: [scale, 0.01, 0.1]} cv GroupKFold(n_splits5) # 按被试分组杜绝同被试泄漏 gs GridSearchCV(pipe, grid, cvcv, scoringaccuracy, n_jobs-1) gs.fit(X, y, groupsgroups) return gs.best_estimator_class_weightbalanced是为了应对四类样本不均衡。按 5 分阈值切四象限时高唤醒高效价的样本往往偏多低唤醒低效价的偏少不加权的话 SVM 会倾向于把样本都判给多数类总体准确本文还有配套的精品资源点击获取