简介:面向情感识别与多模态生理信号处理的研究者、开发者,这份压缩包提供了一套从原始数据到模型验证的完整项目方案,覆盖多模态情感识别的典型流程。包内共39个文件,约548MB,包括MAT格式原始生理信号、pkl格式预处理数据集、Python脚本与Jupyter Notebook建模代码、jpg/png格式训练曲线与结果图,以及Markdown实验报告,目录按原始数据、预处理数据、代码模块分层组织,便于对照学习。已有4135人学习下载,说明其在课程设计、毕业课题和科研入门中具有较强参考价值。内容从数据采集与预处理着手,详细展示了去噪、同步、特征提取(如HRV、EDA),并给出基于CNN、RNN等模型的训练、验证与情感分类实现;报告与代码相互印证,适合生物医学工程、人工智能、心理学方向的读者快速搭建基线系统并开展二次实验。
1. 多模态生理信号情感识别:为什么把EEG、ECG、GSR放一起反而翻车
设想这样一个画面:受试者坐在实验椅上,头上是脑电电极、胸口贴心电、指尖夹着皮电传感器,面前大屏幕轮流播放情绪视频。一个多模态生理信号情感识别系统要在这些时序数据里找到效价(正/负)和唤醒度(平静/激动),输出此刻用户处于什么情绪状态。
这类方案用在人机交互、驾驶疲劳提醒、内容推荐和用户研究里,比问卷打分客观,比表情识别扛得住遮挡。可它的口碑很怪:论文里融合准确率动辄七成以上,自己复现时半数模型连基线都过不了。
原因大多不在模型,而在多模态融合算法的输入侧:三路信号采样率不一样、响应速度不一样、标签切分的时刻不一样,任何一处错位都足以让准确率掉回随机水平。这篇文章按数据到特征、融合到模型、避坑到报告的顺序,把一套可复现的完整流程讲透。
2. 数据与预处理:选对公开数据集,并按统一时钟把三路信号对齐
2.1 公开数据集怎么选:标注维度与信号深度
先别急着写特征提取,第一步是数据。做这个方向最常见的是下面几个公开数据集,各有各的脾气。
| 数据集 | 典型信号 | 情感标注 | 适合干什么 |
|---|---|---|---|
| DEAP | 32导EEG 加上 ECG、EMG、GSR、PPG 与面部视频 | 效价、唤醒、支配、喜欢(1-9 评分)及每秒连续标签 | 多模态入门首选,信号通道多,社区参考代码最密 |
| WESAD | 腕部 BVP/EDA/体温,胸部 ECG/EMG/EDA/体温 | 压力、放松、娱乐、基线等离散状态 | 可穿戴场景、压力监测,离散标签简单,最快出结果 |
| SEED | 62导EEG | 正、负、中性三类 | 单模态 EEG 对比实验、跨被试泛化测试 |
| MAHNOB-HCI | 32导EEG 加 ECG、GSR、温度与视频 | 连续效价与唤醒评分 | 连续维度回归、标签时间对齐研究 |
选型经验是:只想快速跑通,先用 WESAD 这种离散状态数据,标签干净、通道少、不用做复杂的连续评分回归;想贴近“多模态生理信号”这个方向,DEAP 更合适,脑电和外周信号都有,实验范式成熟。容易出现的问题是看到数据集大就直接上,结果预处理代码写了一周还没跑过一轮训练。我一般先看两个东西:标注是离散类别还是连续维度,各信号的时间戳是不是跟刺激事件对齐。第二个条件不满足,后面所有融合都白搭。部分数据集需要按官方流程申请审批后再使用,以官方页面要求为准。
2.2 EEG、ECG、GSR 的清洗流程:工频、漂移与伪迹
拿到手的数据永远是脏的:工频干扰、基线漂移、眨眼伪迹、运动伪迹混在一起。常见做法是分信号处理,绝不共用同一个滤波参数。EEG 先带通再陷波:
from scipy import signal def pipeline_eeg(raw, fs=128, notch=50): # 0.5~40Hz 带通:保留 theta/alpha/beta 主要情绪相关频带 b, a = signal.butter(4, [0.5, 40], btype='band', fs=fs) out = signal.filtfilt(b, a, raw, axis=-1) # 陷波滤除工频,50Hz 常见,60Hz 地区改参数 b2, a2 = signal.iirnotch(notch, 30, fs) out = signal.filtfilt(b2, a2, out, axis=-1) return out带通要选 4 阶 butter,太低压不住带外噪声,太高引入相位畸变;filtfilt 是零相位滤波,避免滤波后波形整体偏移毁掉事件边界。陷波带宽 Q 设 30,太窄滤不干净,太宽把相邻频段的脑电能量也削掉了。ECG 的处理逻辑完全不同,重点不是频段而是保住 R 波峰值:
import numpy as np def pipeline_ecg(raw, fs=256): b, a = signal.butter(2, [0.5, 40], btype='band', fs=fs) ecg = signal.filtfilt(b, a, raw, axis=-1) # 找 R 波:distance限制300ms内不能有两个心拍 from scipy.signal import find_peaks peaks, _ = find_peaks(ecg, distance=int(0.3*fs), height=np.percentile(ecg, 90)) return ecg, peaksECG 滤波只用 2 阶,阶数高了 R 波会被削圆,导致后续心拍间期(RR interval)计算全部偏大。height 用第 90 百分位做自适应阈值,比固定 0.5mV 靠谱,因为不同被试心电幅值差异很大。GSR 又是另一套逻辑,它是慢信号,重点是拆出两类成分:
def pipeline_gsr(raw, fs=10): # GSR 本身是缓变信号,0.5Hz 以下属于tonic基线 b, a = signal.butter(2, 0.5, btype='low', fs=fs) tonic = signal.filtfilt(b, a, raw, axis=-1) phasic = raw - tonic # 快速响应分量,跟情绪唤醒强相关 return tonic, phasic严格做法是用 cvxEDA 做凸优化分解,但教学级复现里 tonics/phasic 减法近似已经能跑,后续要发表论文再换严格方法。GSR 采样率不用高,公开数据集里 10Hz 甚至更低都够用,强行重采样到 128Hz 只会制造虚假细节。
2.3 切窗与标签对齐:多模态统一处理的第一道关卡
多模态统一处理首先要解决的从来不是模型,是时间轴。三路信号各按各的时钟进计算机,刺激事件也是独立时间戳,不对齐就融合等于把三个不同故事拼成一个故事。切窗代码看着简单,参数里全是坑:
def make_windows(timestamps, labels, fs=128, win_len=4, hop=2, label_delay=1): win_samples, hop_samples = win_len * fs, hop * fs delay_samples = int(label_delay * fs) windows, targets = [], [] for start in range(0, len(timestamps) - win_samples, hop_samples): # 标签不取窗口中心,而是取窗口起点之后 delay 秒 seg_label = int(labels[start + delay_samples]) windows.append([start, start + win_samples]) targets.append(seg_label) return windows, targetswin_len=4s、hop=2s 是常见起点组合,窗口重叠能增大样本量,但也让相邻样本高度相关,所以训练集和测试集的切分必须在切窗之前按受试者完成,否则后面全串味。label_delay=1s 对应 GSR 这类慢响应的生理延迟,具体数值建议查看数据集的记录文档或自己做一次峰值延迟统计。EEG/ECG 反应快得多,标签延迟设 0 通常够。切窗的时候保留整段信号,别在窗口内提前平均成一条曲线——特征提取应该在窗上做,而不是在点上做。
3. 特征提取与融合:让“多模态融合算法”不止是拼接
3.1 从三路信号里抽什么特征:时域、频域与非线性
特征设计的核心是“跟情绪状态相关”,不是“把信号描述完整”。EEG 常用的差分熵和功率谱密度,ECG 常用心率变异性,GSR 常用 phasic 响应幅度。一个能跑通全流程的特征提取函数长这样:
def extract_all(eeg, ecg_peaks, gsr_phasic, fs=128): from scipy.signal import welch feats = [] # EEG:四个频带的 log 功率,情绪研究最常用 theta/alpha/beta for band in [(4,8),(8,13),(14,30),(30,45)]: f, pxx = welch(eeg, fs=fs, nperseg=256) mask = (f>=band[0]) & (f<band[1]) feats.append(np.log10(np.sum(pxx[mask]) + 1e-9)) # ECG:心率变异性,RMSSD/SDNN 都是标准指标 rr = np.diff(ecg_peaks) / fs * 1000 # 转毫秒 if len(rr) > 1: rmssd = np.sqrt(np.mean(np.diff(rr)**2)) sdnn = np.std(rr) feats += [rmssd, sdnn] # GSR:phasic 均值,教学简化版用均值,论文请用SCR检测 feats.append(np.mean(np.abs(gsr_phasic))) return np.array(feats)EEG 的 welch 函数 nperseg=256 在 128Hz 采样率下对应 2s 频谱分辨率,适合 4s 窗口;窗太短频谱糊,窗太长丢掉时变信息。ECG 的 diff 会牺牲一个心拍,窗口内少于两次完整心拍的样本建议直接丢弃。GSR 特征对噪声极其敏感,手汗大的人在放松状态都能摸出高幅值,所以后面归一化时必须按受试者做。
3.2 特征级融合:归一化顺序决定模型能不能收敛
特征级融合是“多模态融合算法”里最常用的一类,讲白就是三路特征拼成一个长向量。很多人栽在第一行代码:先拼接再归一化,结果 EEG 的 log 功率和 GSR 的均值差着三个数量级,模型梯度基本被大数值特征绑架。正确顺序是先归一化再拼接,而且归一化统计量只能从训练集算:
def zscore_on_train(train_feats, test_feats): # 只使用训练集的均值/方差,防止数据泄漏 mean = train_feats.mean(axis=0) std = train_feats.std(axis=0) + 1e-8 train_norm = (train_feats - mean) / std test_norm = (test_feats - mean) / std return train_norm, test_norm1e-8 是给方差做地板,防止某些恒定的特征除出 inf。这个“只用训练集统计量”的习惯,是从传统机器学习一路带过来的,但到了深度学习的端到端模型里经常被忽略,很多人直接在全部数据上算均值方差,测试性能虚高,换成跨被试验证立刻原形毕露。
3.3 数据级与决策级:两种同样值得试的替代路线
很多多模态融合论文会把融合层次分成三类,实际工程里我也按这三条路走。数据级融合是把三路信号重采样到同一时刻,堆叠成多通道时序直接喂模型,让模型自己学跨模态关联,上限高但输入维度膨胀,预处理复杂;决策级融合是各训练一个单模态分类器,输出概率再做加权平均,模块化最好,一个模态崩了别的还能兜底,代价是学不到模态间的互信息。
| 融合层次 | 输入形态 | 优点 | 代价 |
|---|---|---|---|
| 数据级 | 原始信号多通道堆叠 | 能学跨模态时序关联,上限高 | 数据和计算量最大,对齐要求最严 |
| 特征级 | 手工特征拼接 | 实现快,可解释性强 | 依赖特征设计的质量 |
| 决策级 | 各单模态预测概率 | 鲁棒,模块可替换 | 丢失模态间相关性 |
我现在做项目一般先上特征级拿到基线,再补决策级做对照,最后有时间才尝试数据级。如果要追灌顶效果,对标多模态大模型的那种“文本+图像”统一处理思路,可以尝试在数据级上用卷积或 Transformer 直接吃多通道生理信号,但生理信号只有几条通道而且采样率差异大,收益往往不如把特征级做扎实来得快。
4. 搭建多模态情感识别模型:代码与逐段说明
多模态模型代码复现死在两个地方:输入张量形状不对,标签维度不对。下面按能跑通的最小闭环来。这里不碰任何私有协议,纯 PyTorch 加上公开数据集就能复现。
4.1 第一步:特征拼接 + MLP,最小可用方案
把上一章得到的特征矩阵直接喂进 MLP,这是整个多模态识别系统最稳的基线。先定义网络:
import torch import torch.nn as nn class FeatureMLP(nn.Module): def __init__(self, in_dim, num_classes=3): super().__init__() self.net = nn.Sequential( nn.Linear(in_dim, 128), nn.ReLU(inplace=True), nn.Dropout(0.3), nn.Linear(128, 64), nn.ReLU(inplace=True), nn.Linear(64, num_classes), ) def forward(self, x): return self.net(x) # in_dim 来自特征维度,比如 EEG 4频段 + ECG 2 + GSR 1 = 7 model = FeatureMLP(in_dim=7, num_classes=3)训练循环同样保持最小:
def train_epoch(model, loader, opt, loss_fn): model.train() total, correct = 0, 0 for x, y in loader: opt.zero_grad() logits = model(x) loss = loss_fn(logits, y) loss.backward() opt.step() total += y.size(0) correct += (logits.argmax(dim=1) == y).sum().item() return correct / totalhidden=128/64 是常见配置,数据量大可以加宽到 256/128,数据量小反而要缩窄防止过拟合。Dropout 0.3 在特征量少时能明显压低训练集和测试集的差距。优化器用 Adam,lr 从 1e-3 起步,batch_size 32,这三个参数在多数公开数据集上不需要大动。
4.2 第二步:用 LSTM 把时序结构装进模型
特征拼接的最大损失是时间结构:4 秒窗口里的“先紧张后放松”被压成了平均值。想要时序信息,把窗口按时间步拆开,每个时间步送一路特征,LSTM 去学顺序关系:
class MultiModalLSTM(nn.Module): def __init__(self, in_channels, hidden=64, num_layers=2, num_classes=3): super().__init__() self.lstm = nn.LSTM(in_channels, hidden, num_layers, batch_first=True, dropout=0.3) self.fc = nn.Linear(hidden, num_classes) def forward(self, x): # x: (B, T, C),B 是批次,T 是时间步,C 是每步特征 out, _ = self.lstm(x) out = out[:, -1, :] # 取最后时刻的隐状态 return self.fc(out)LSTM 的 dropout 有个隐蔽特性:单层网络里 dropout 不生效,必须 num_layers>=2 才有意义,所以这里用 2 层。想要轻量替代可以换成 GRU,参数量少三分之一,在短窗口生理信号上效果通常差不多。数据准备时保证每一批样本的 T 相同,padding 会引入虚假的边界状态。
4.3 训练策略与评估:划分、早停与指标选择
模型代码跑通只是第一步,真正决定能不能复现的是划分策略。按受试者划分,而不是按样本随机划分:
subjects = list(range(1, 33)) # 32位受试者 test_subjects = subjects[::5] # 每5位抽1位作为测试 train_subjects = [s for s in subjects if s not in test_subjects]这块代码放在切窗之前执行,否则同一受试者的重叠窗口会同时出现在训练和测试里。评估只看准确率不够,至少补上加权 F1 和混淆矩阵。准确率容易被中性情绪占比很高的数据集带偏,F1 能反映少数类是否被牺牲。
5. 多模态情感识别避坑指南:五个高频翻车点
5.1 数据泄漏:同一个人既进了训练集也进了测试集
现象:按样本随机划分训练集和测试集,准确率冲到 90% 以上,换成按受试者划分立刻掉到 60%。
原因:同一受试者的相邻 4 秒窗口高度相似,模型其实在记忆被试身份,没有学到情绪模式。
解决:所有划分操作在切窗之前按 subject id 完成。跨被试验证才是这个领域的通用做法。
5.2 GSR 慢反应被当成即时响应
现象:加上 GSR 通道之后,融合模型准确率不升反降,单模 EEG 反而更好。
原因:GSR 的 phasic 峰值通常比刺激出现晚 1 到 3 秒,标签时刻对应的是上一个刺激的情绪,GSR 特征被错位标签教坏。
解决:在切窗时对 GSR 通道做 label_delay 补偿,或者干脆把 GSR 特征的标签对齐到窗口末尾之后的事件时刻。先自己画一条“刺激时刻到响应峰值”的时间轴再设参数。
5.3 把不同采样率直接插值硬对齐
现象:把 256Hz 的 ECG 线性插值到 128Hz,融合效果变差,R 波检测出的心拍间期乱跳。
原因:线性插值会把 R 波极值削平,峰值位置偏移几个采样点,RR 间期计算对峰值位置极其敏感。
解决:先在各信号的原始采样率上完成检测和特征提取,再对特征时间序列做对齐;不要拿原始信号做重采样,拿到的是虚假的“高频”信号。
5.4 类别不均衡下的准确率幻觉
现象:中性情绪占了七成样本,模型全部预测中性也有 70% 准确率,看起来还不错,实际上一点用没有。
原因:准确率在长尾分布上没有区分度。情绪数据集天然不均衡,平静状态总是比强烈情绪多。
解决:报告加权 F1 和混淆矩阵,给训练损失加 class weights,让少数类错分的代价更大。发布结论时把各类别的召回率分开写。
5.5 用全量数据做归一化统计量
现象:离线 Z-Score 之后训练集和测试集都漂亮,换一批被试立刻崩。
原因:归一化的均值和方差来自测试数据,等于测试时偷看了答案。
解决:归一化统计量只能从训练集计算,测试集只做变换不参与统计。把 zscore_on_train 写成固定的处理函数,杜绝顺手在全量数据上算均值。
6. 报告与验证:把实验写成能复现、能说服人的结果
6.1 报告里必须写清的三个“实验边界”
带报告的价值在于别人能照着复现。三个边界必须写死:数据边界(数据集版本、包含哪些通道、排除了哪些被试和坏窗口)、预处理边界(滤波参数、窗口长度、步长、GSR 延迟补偿秒数)、训练边界(模型结构、优化器、学习率、batch size、随机种子、训练测试划分方式)。我见过太多报告只写准确率不写划分方式,导致同组人自己都复现不出自己的结果,更别提前后版本对比。
6.2 用消融实验证明“多模态比单模态强”
光报一个融合后的 75% 准确率说服不了任何人,得证明这 75% 是“融合”出来的,不是单条信号本来就有的。消融实验按这个模板做:
| 实验 | 准确率 | 加权 F1 |
|---|---|---|
| 单模态 EEG | ||
| 单模态 ECG | ||
| 单模态 GSR | ||
| 特征级融合 + MLP | ||
| 数据级融合 + LSTM |
如果融合结果和最好的单模态差不多,说明多模态没有实际增益,要检查是不是某一模态信号质量太差拖了后腿,或是对齐仍然有误。真正的多模态增益应该表现为:融合准确率高于任何一个单模态,而不是等于最高那个。
6.3 把误分类样本做成可视化的调试集
报告写得再漂亮,也抵不上一张误分类分布图。把跨被试测试集里预测错误的样本收集起来,用 t-SNE 降维到二维,按真实情绪着色,再按预测情绪画边缘,很快能看出模型把哪些类别互相推搡。我常遇到的规律是“高唤醒负效价”和“低唤醒负效价”之间的界线最模糊,因为两者的 GSR 幅值分布高度重叠。这些样本多半对应坏窗口或弱生理反应片段,把它们单独列成调试集,逐个看原始波形,比盲目加网络层数有效得多。
现在我养成的习惯是每次实验前先画一条“刺激时刻到三路信号响应时刻”的时间轴,确认每一路信号在哪一秒开始响应、标签在哪一秒有效,再动任何模型代码。这个习惯帮我在报告阶段省下大量返工时间,也让实验结果经得起别人复现。希望帮到你。
本文还有配套的精品资源,点击获取