平时常有人问我:情绪识别这种活儿,为什么非要在脑电信号上死磕?脑电设备贵、佩戴麻烦、实验准备时间长,动辄二十分钟才能进入稳定采集状态。与之相比,心电信号(ECG)采集门槛低得多,两三个电极贴在胸口就能拿到稳定波形,而且它和自主神经系统的关系非常直接——紧张、放松、兴奋、疲惫,心率变异性都会跟着变。这几年我做得最多的情绪分类方案,就是用MATLAB把原始心电信号送进深度学习网络,让模型自己从波形里学情绪特征。这套流程跑通之后,从数据清洗到模型训练再到效果评估,都能在一套工具链里完成,不需要来回切换Python和Matlab,复现成本很低。这篇文章就把完整链路拆开讲清楚,适合正在做生物医学信号处理、情感计算课题的学生,也适合想用ECG做状态监测的工程师参考。
1. 情绪识别为什么选心电信号,而不是去卷脑电
1.1 心跳不只是泵血,它带着情绪信息
很多人有个误解,觉得心电信号只能看心脏是否健康。其实自主神经系统对心脏的调控非常灵敏:人在紧张时交感神经兴奋,心率上升,相邻心跳间隔(R-R间期)的变异性会明显压缩;放松时副交感神经占优,心率下降,R-R间期波动幅度增大。这种细微变化在几秒钟的ECG波形里就能体现出来,尤其体现在每个心动周期的形态和节律稳定性上。
情绪心理学通常用效价(valence,正面还是负面)和唤醒度(arousal,兴奋还是平静)两个维度描述情绪状态。比如“高兴”是高效价高唤醒,“平静”是高效价低唤醒,“焦虑”是低效价高唤醒,“悲伤”是低效价低唤醒。传统方法会用HRV频域特征(LF/HF比值)、时域特征(SDNN、RMSSD)来做分类,但问题在于每个人的心率基线差异很大,同一个特征在不同人身上阈值完全不同。深度学习的好处是,它不要求你手工定义特征,而是直接从原始波形或简单的时频表示里学习出对情绪敏感的模式。
1.2 与脑电、肌电、皮电对比,ECG的性价比在哪里
情绪识别可用的外周生理信号不少,皮电(GSR)对唤醒度很敏感,肌电(EMG)能反映表情相关肌肉活动,脑电(EEG)则是情绪研究里的“标配”。但放在工程落地和复现角度上,ECG有它独特的优势。
- 脑电信号幅度只有微伏级,受眼电、肌电干扰严重,预处理麻烦,而且实验环境稍有不稳,数据质量就崩了。
- 肌电信号个体差异极大,且与具体动作耦合严重,情绪相关成分不容易分离。
- 皮电信号时间响应慢,情绪刺激结束好几秒后才出反应,时间对齐很难做。
- ECG信号幅度是毫伏级,信噪比相对高,采集设备成熟、便宜,佩戴方式也简单。
深度学习模型对输入质量的要求很高,信号本身信噪比太低,后面无论用什么结构都难救回来。ECG在这方面的天然优势明显,这也是我选择它作为情绪识别输入信号的核心原因。
1.3 这套方案适合谁,解决什么问题
这套方案的适用场景包括但不限于:情感计算方向的课程设计、基于可穿戴设备的情绪状态监测原型、驾驶疲劳/学习状态评估的前期实验,以及任何想在生理信号上跑深度学习方法但不想陷在复杂预处理里的研究。它的核心价值在于,把一个原本需要信号处理专家手工设计特征的识别任务,变成了一个“数据清洗→喂网络→出结果”的标准深度学习流程。
2. 数据准备:想拿到干净的ECG情绪样本,远比想象中复杂
2.1 公开数据集怎么选,诱发范式怎么理解
做这个项目,我不建议自己从零去收集情绪状态下的心电数据——情绪诱发实验需要严格控制视频、音乐等刺激材料,还要解决被试配合度问题,一套实验做下来一两个月就没了。更靠谱的方式是使用公开的情绪生理数据集。
这类数据集的基本范式通常是:招募一批志愿者,佩戴多导生理记录设备,然后播放能诱发特定情绪的影片片段,同时记录多个通道的生理信号,包括ECG、EEG、GSR等。受试者每看完一段视频,要对自己的情绪状态打分,最常用的是Self-Assessment Manikin量表,得到效价和唤醒度的主观评分。拿到这些分数之后,就可以根据你需要几分类来决定标签怎么切:
- 二分类:只区分高/低唤醒度,或者正/负效价。
- 四分类:用效价和唤醒度组成四个象限,对应高兴、平静、焦虑、悲伤四类情绪。
- 三分类:可以设置正性、中性、负性,或者放松、中性、紧张。
我通常建议从四分类入手。二分类太粗糙,模型很容易靠一个心率特征就分对了,体现不出深度学习的价值;八分类、十分类的细粒度划分主观评分往往会重叠,类别边界很模糊,模型训练难度大且效果不易收敛。四象限的划分既符合情绪心理学的基本模型,分类任务难度也适中。
2.2 ECG通道抽取与统一的组织方式
公开数据集记录的是多导联或多通道生理信号,需要做两个关键操作。
第一,从原始数据文件中抽出你需要的ECG通道。以记录时使用多路信号为例,你需要确认哪一路是肢体导联I或II的心电信号,通常选择波形中QRS波群最清晰的那一路。
第二,统一采样率和单位。不同数据集的采样率可能是256Hz、512Hz或1000Hz,信号的幅值单位可能是mV、V或ADC码值。除非你打算用原始ADC值做归一化,否则建议先做单位统一,转换为mV量级,后续处理心智负担会小很多。重采样在MATLAB里用resample函数即可,不过要注意滤波后再降采样,避免混叠。
2.3 样本组织方式:以“片段”为单位还是以“心跳”为单位
拿到连续ECG和刺激片段标签后,有两种常见的组织方式。一种是以“心跳”为单位,先把R波检测出来,然后围绕每个心跳截取固定长度波形,例如RR间期的前后各0.3秒窗长,这样一个样本就代表一次心脏搏动。另一种是以“固定时间片段”为单位,直接按时间窗切,比如每5秒一个样本,窗口内可能包含5至8次心跳。
我在实际项目里更倾向于后者。原因很实际:情绪反应是秒级尺度的生理状态变化,单次心跳的形态差异更多反映的是心电本身的变化,而不是情绪状态;而包含多次心跳的连续片段,既保留了心跳形态信息,又能让网络观察到节律变化信息,也就是RR间期序列的波动情况。你甚至不用显式提取RR间期,只要模型有足够感受野,它自己能从波形间隔上学到节律特征。
3. 预处理:把原始波形变成“适合深度学习吃的样子”
3.1 ECG噪声从哪里来,为什么必须处理
工业环境里做ECG信号处理,你面对的噪声通常有三类:基线漂移、工频干扰、肌电伪迹。每类噪声的处理手段都不一样,这也是很多人一上来就把代码跑崩的原因。
- 基线漂移是由呼吸、身体移动或电极接触阻抗变化引起的低频成分,频率通常在0.05Hz到几赫兹之间,会让波形整体上下缓慢浮动。
- 工频干扰是电网产生的50Hz(或60Hz)窄带噪声,幅度不大但无处不在,不处理的话馈入深度学习模型就是一个持续存在的固定模式。
- 肌电伪迹是肌肉收缩产生的宽带高频噪声,能量集中分布在20Hz以上,雷雨天气、紧张状态下尤其明显,幅度甚至能淹没QRS波群。
3.2 滤波器参数到底怎么选,MATLAB里怎么写
预处理链路我建议按固定顺序做:先去工频干扰,再去基线漂移,最后做低通滤波抑制高频肌电。
工频干扰使用陷波滤波器,在50Hz处挖一个窄带凹槽。MATLAB里可以用iirnotch设计:
fs = 256; % 采样率 f0 = 50; % 工频 bw = 2; % 带宽 [num, den] = iirnotch(f0/(fs/2), bw/(fs/2)); ecg_filtered = filtfilt(num, den, ecg_raw);基线漂移可以用高通滤波去除,截止频率设在0.5Hz。注意不能用普通的差分运算代替高通滤波,那会把整个波形形态彻底改变。更稳妥的做法是用零相位滤波filtfilt,避免相位偏移带来的波形失真:
[b, a] = butter(2, 0.5/(fs/2), 'high'); ecg_no_baseline = filtfilt(b, a, ecg_filtered);肌电干扰用一个截止频率40Hz左右的低通滤波压掉。如果切得过高,比如到100Hz,肌电噪声会大量残留;如果过低,比如20Hz,QRS波群的高频成分会被削掉,导致R波峰变钝。经验值是30Hz到45Hz之间,我一般用40Hz。
[b, a] = butter(4, 40/(fs/2), 'low'); ecg_clean = filtfilt(b, a, ecg_no_baseline);3.3 滑窗切样本,别让标签时间对齐出错
预处理完之后进入切窗阶段。窗口长度和步长是两个非常关键的超参数。窗口太短,比如1秒,可能只包含一个心跳,情绪信息不够;窗口太长,比如30秒,单个样本计算量太大,而且情绪可能在片段内有变化,标签纯度下降。我试下来,5秒窗口是一个比较均衡的选择,对于256Hz采样率来说就是1280个采样点。如果算力允许,也可以试试8秒到10秒的窗口,网络能捕捉到更长的节律变化。
步长决定了样本之间的重叠程度。步长等于窗口长度,则样本互不重叠,总样本数少,训练效率低;步长设为窗口的一半甚至四分之一,则相邻样本之间高度相似,增加了样本量,但也引入了严重的重复信息。这里要非常注意:在训练集和测试集划分时,不能因为滑窗重叠导致同一个原始片段既出现在训练集又出现在测试集,否则就是数据泄露,验证结果会虚高。通常我会把同一个受试者的所有样本放在同一边,或者至少确保切分后训练和测试窗口之间没有时间交叉。
MATLAB里切窗可以用buffer函数,也可以自己写循环:
win = 5 * fs; % 窗口长度 step = 2.5 * fs; % 步长 n_samples = floor((length(ecg_clean) - win) / step) + 1; X = zeros(win, n_samples); for k = 1:n_samples start_idx = (k - 1) * step + 1; X(:, k) = ecg_clean(start_idx : start_idx + win - 1); end切出来的每个样本还要做一次z-score归一化,减均值除以标准差。这个步骤非常关键,因为不同人的ECG幅值差异很大,有些人是0.5mV,有些人是1.5mV,网络如果不做归一化,会把幅值大小误当成情绪相关信息来学习。
3.4 数据增强:样本不够时怎么办
公开情绪数据集通常只有二三十个受试者,切成样本之后总量不大,深度学习模型很容易过拟合。我会用一些针对ECG特点的增强手段:
- 加噪声:在干净信号上叠加低幅值高斯白噪声或工频残留,让模型对轻微干扰更鲁棒。
- 时间伸缩:把窗口在时间轴上做轻微拉伸和压缩,模拟心率快慢变化。
- 幅度缩放:乘以一个接近1的随机系数,模拟电极接触阻抗变化带来的幅值波动。
- 时间偏移:在原始连续信号上随机偏移起点位置重新切窗,制造出不完全相同的样本。
这些增强操作都要在样本进入网络之前完成,而且必须保证同一增强操作下的标签不变。情绪标签是按片段打的,做小幅度的信号变换不会改变情绪性质,这一点可以放心。
4. 模型设计:一维卷积吃掉波形,双向LSTM捕获时序
4.1 为什么不用手工特征,让网络自己学
传统方案提取HRV特征(RMSSD、SDNN、LF/HF等)再用支持向量机分类,能做到70%左右的分类准确率,但泛化很差。换一批受试者,同一套特征就不灵了。深层原因是手工特征的表达能力有限,HRV特征只统计了RR间期的统计量,完全没有利用P波、QRS波群、T波的形态变化信息。而这些形态变化恰恰可能包含情绪相关的细节。
所以我选择端到端路线:输入原始波形,输出情绪类别。让网络自行决定关注形态特征、节律特征,还是一个都不要。CNN擅长提取局部波形特征,LSTM擅长捕获时间顺序上的依赖关系,两者组合在一起正好匹配ECG信号的特点:每个心跳有典型的波形形态,心跳之间有序的节律变化。
4.2 MATLAB里的网络结构怎么搭,参数怎么选
我用的是CNN-LSTM混合结构,在MATLAB里用dlnetwork来定义:
layers = [ sequenceInputLayer(1) convolution1dLayer(16, 32, 'Padding', 'same') batchNormalizationLayer reluLayer maxPooling1dLayer(4, 'Stride', 4) convolution1dLayer(8, 64, 'Padding', 'same') batchNormalizationLayer reluLayer maxPooling1dLayer(4, 'Stride', 4) lstmLayer(64, 'OutputMode', 'last') fullyConnectedLayer(4) softmaxLayer classificationLayer ];第一个卷积层的卷积核大小取16个采样点,在256Hz采样率下对应约62毫秒,刚好能覆盖一个QRS波群的大部分能量。第二层卷积核取8个采样点,进一步提取更精细的细节特征。两次池化把1280点的输入序列逐步压缩到80个时间步,再进入LSTM。LSTM单元数量选64,这个参数对计算量、过拟合风险都比较友好。
刚接触深度学习的读者可能会问,为什么第一层卷积核不取大一点,比如32或者64?因为ECG信号里最显著的形态特征就是QRS波群,它本身持续80毫秒到100毫秒左右,卷积核取得太大,会把P波、T波等持续时间较短的细节全部平滑掉。核取与关键形态时长匹配的尺寸,往往比盲目堆大核更有效。
4.3 类不平衡与标签编码
情绪四分类的样本分布往往不均匀。大多数人看电影片段时,正性高唤醒(高兴)和负性低唤醒(悲伤)类别的评分差异可能很清晰,但“正性低唤醒”和“负性低唤醒”的边界就很模糊。这种模糊会导致模型把难分样本硬分到样本量更大的类别里。处理办法两个方向:一是取样层面的平衡采样,让每个batch里四个类别的样本数量大致相等;二是损失函数层面使用加权交叉熵,对样本量小的类别施加更大惩罚权重。我倾向于两者都用,每次迭代所见的类别分布更稳定。
4.4 别把网络堆太深
我见过不少人在这个项目上一上来就堆ResNet50、堆Transformer,然后发现效果反而不如一个两层CNN加单层LSTM的小网络。原因不复杂:训练样本量就那么大,深度网络参数量太大,全部都在背训练集,验证集上表现惨不忍睹。在有限的生理信号样本条件下,优先保证模型的归纳偏置正确,小网络更容易学到可迁移的模式。想上线再慢慢加深参数,这是性价比最高的路线。
5. 训练、验证与指标解读:准确率高不等于真的准
5.1 训练配置与超参数选择
训练参数我常用的基准配置如下:
| 超参数 | 推荐值 | 说明 |
|---|---|---|
| 优化器 | Adam | 适合中等规模数据集,收敛稳定 |
| 初始学习率 | 1e-3 | 过大会震荡,过小收敛太慢 |
| Batch size | 32 | 兼顾显存和梯度估计稳定性 |
| Epochs | 30 | 配合早停策略 |
| 早停条件 | 验证损失连续5轮不下降则停止 | 防止过拟合 |
| 验证集比例 | 20% | 必须按受试者划分 |
在MATLAB里训练可以用trainNetwork,配合验证集指定ValidationData参数。如果你的输入是原始信号矩阵X和标签向量Y,形状为“特征维度×时间步×样本数”,注意顺序要和sequenceInputLayer的期望一致。
5.2 按受试者划分,不要随机打乱划分
这是整个项目里最容易出问题、也最影响结果可信度的一步。如果直接把所有样本随机打乱再按比例划分训练集和测试集,由于滑窗有重叠,同一个连续片段的一部分样本会进入训练集,另一部分进入测试集,模型相当于考试时见过同一道题,测试准确率虚高到90%以上都有可能。但如果按受试者划分,不同人的ECG形态差异大、心率基线不同,测试准确率会明显下降,比如落到65%到75%。这后者才是真实可期望的水平。
正确做法是先在受试者级别划分,把所有样本按受试者完整分成训练组和测试组,保证任意一个受试者的样本不同时出现在两边。能在这种划分下保持较高准确率的模型,才具备在真实场景中推广的潜力。
5.3 评估指标不止看准确率,还要看F1和混淆矩阵
四分类任务里准确率容易掩盖问题。举个例子,四个类别分别占比40%、30%、20%、10%,模型把所有样本都预测成第一类,准确率也有40%。这显然不能证明模型有效。额外要看的指标:
- 宏平均F1:对每个类别分别计算F1再取均值,类别不平衡时会给出更诚实的评价。
- 混淆矩阵:直观看出哪些类别之间最容易混淆。通常你会发现正性低唤醒和负性低唤醒边界确实模糊,因为这两种状态的心率变化模式本身就不显著。
- 单被试交叉验证:每次用一个受试者做测试,其余训练,把所有结果汇总成一个整体混淆矩阵。这种方式最能反映个体差异对模型的影响。
MATLAB里confusionchart可以一行出图:
cm = confusionchart(actual_labels, predicted_labels); cm.Normalization = 'row-normalized';归一化之后的混淆矩阵可以发现,高唤醒类别之间的混淆率往往远低于跨唤醒度的混淆率,说明模型更多依赖唤醒度线索做出判断,效价线索还比较弱。如果出现这个现象,可以考虑在输入端补充额外的效价相关特征,或者增加对T波形态敏感的滤波设置。
5.4 特征可视化:判断网络学到了什么
训练完的模型不一定能直接看出“学到了情绪特征”。我一般会把最后一层全连接层之前的特征提取出来,用t-SNE或PCA降维到二维平面观察聚类情况。如果训练集上同类样本聚得很紧,测试集上也能看到明显的类别分离倾向,说明网络不是靠死记硬背。MATLAB里activations函数可以提取中间层输出,之后套用tsne函数即可。
这一段可视化工作对写项目报告尤其有用。评审和答辩最关心的就是“你的模型到底在学什么”,一张特征分布图比任何准确率数字都有说服力。
6. 我在复现这套流程时踩过的坑
6.1 过度滤波把QRS波群滤没了
最典型的一次,我把低通截止频率设在20Hz,本意是尽量滤掉肌电噪声。结果训练出来的模型在验证集上一塌糊涂。回头看预处理后的波形才发现,R波峰被明显削平了,整个QRS波群变得圆滑,原来清晰的形态特征全被平滑掉了。后来改成40Hz截止频率,波形形态保住了,准确率也恢复正常。滤波不是越狠越好,要根据目标信号的频带范围选择参数,并且每次滤波后务必可视化检查。
6.2 基线漂移没去干净,模型学了坐姿而不是情绪
有一版实验数据是在被试坐姿休息和观看视频刺激两种状态间切换采集的,基线漂移非常明显。我当时偷懒只做了高通滤波,截止频率设成0.05Hz,低频漂移大部分还在。结果模型表现很好,但后来才发现它学到的是“坐着不动”和“躺着看电影”的姿态差异,而不是情绪差异。这个坑直接说服我把基线校正步骤做成强制项,信号处理阶段宁可多花时间,也不能给网络留这种捷径。
6.3 时间重叠的样本划分,训练集和测试集“串供”
刚开始做实验时,我直接对样本集随机划分,测试准确率高达94%,当时还挺高兴,后来仔细检查滑窗参数才发现,训练集和测试集的很多样本只是相位偏移不同,内容几乎一模一样。改成按受试者划分之后准确率掉到72%左右,这个数值虽然不好看,但它是真实可信的水平。从此以后,我所有生理信号实验的划分策略都固定为先按被试分组。
6.4 个体差异导致分类器学的是“人”而不是“情绪”
按受试者划分后模型准确率上不去,还有一个常见原因:每个人的ECG幅值、心率、基线漂移模式差异太大,模型很容易利用这些被试相关的特征来“作弊”。解决办法是在预处理阶段先做一次Z-score归一化,降低绝对幅值的影响;有条件的话还可以对每个受试者单独做数据标准化,本质上就是去掉个体均值再训练,让模型更关注形态和节律的变化模式。实测这个简单操作能带来5到8个百分点的提升。
6.5 训练过程不稳定:损失曲线跳动做成早停误判
CNN-LSTM模型在情绪分类上的训练曲线通常不会很平滑。有一轮损失突然升高,早停机制误判为开始发散,直接终止训练,得到的模型明显欠拟合。我的对策是早停的判定不盯着某一轮的验证损失,而是对验证损失做3轮滑动平均,再判断是否连续5轮没有更新最低记录。小改动,效果很稳定。
最后再分享一个小技巧
整套流程跑通之后,我在输出最终模型结果和报告图表时会刻意保存三个阶段的可视化结果:原始波形与预处理后波形的对比图、训练过程的准确率和损失曲线、测试集的混淆矩阵。这三张图基本覆盖了一个情绪分类项目从数据质量、训练过程到模型性能的全部关键信息。如果你要做汇报或写结项报告,这三张图比自己拼凑的指标表格有说服力得多。
另外,模型结构一旦确定,记得把所有超参数和实验结果记录在一个表格里。情绪分类本身的个体差异很大,同一个模型在不同数据集上的结果波动可能超过10个百分点,没有完整的超参数记录,后续想定位模型差异会非常痛苦。这个习惯帮我节省了大量回头查代码的时间,建议你一开始就养成。