多模态生理信号情感识别实战:ECG/EDA/EMG预处理与融合
简介Python实现的多模态生理信号情感识别项目面向计算机相关专业正在准备毕业设计、课程设计或期末大作业的学生也适合希望动手完成情感计算实战练习的开发者。压缩包共39个文件约548MB涵盖原始生理信号mat数据、预处理后的pkl数据集、Python预处理脚本、模型训练Notebook以及损失/精度曲线图等可视化结果能够支撑从数据清洗到模型评估的完整链路。该项目为导师认可的高分毕业设计评审分99分代码完整可运行并配有详细报告适合快速复现多模态情感识别流程也可作为论文写作、答辩展示或项目实战的参考模板。目前已有150人学习下载适合作为入门到进阶的完整实践案例。1. 多模态生理信号情感识别在做什么为什么单路信号总是不够用做过生理信号情感识别的人基本都有同一个感觉单看一路信号很多结论站不住脚。ECG 的 HRV 指标能反映一定压力水平但被试稍微动一下皮电和肌电就把故事带偏了EDA 对唤醒度敏感可它对效价几乎无感知——你紧张、兴奋、愤怒皮电都在涨。多模态生理信号情感识别的核心思路就是把 ECG、EDA、EMG、呼吸等不同生理通道当成不同的“证人”让它们在效价和唤醒度预测上彼此补充。一个完整的高分项目源码里必须包含信号预处理、滑窗切片、特征提取、模态融合、模型训练和按被试划分的评估报告里要有数据说明、参数表、实验对比以及最关键的“数据是怎么划分的”。这篇文章按我实际做这类项目的顺序来写适合正在做课程设计、毕业设计或者想入门多模态情感计算的人直接对照复现。2. 生理信号选型与预处理把 ECG/EDA/EMG 对齐成干净样本2.1 数据集与信号通道怎么选DEAP 的“外周信号”和 CASE 的逐通道标注公开数据里最常被拿来做多模态生理情感识别的是 DEAP 和 CASE。DEAP 虽然以 EEG 出名但它同时记录了 8 路外周生理信号EOG、EMG、EDA、呼吸、脉搏、体温等采样率是 128Hz。如果你把 EEG 也一起用上模型复杂度会立刻上升对情感识别任务来说外周信号往往已经能提供足够的区分度。CASE 数据集则更干净ECG、EDA、EMG、呼吸四路每路信号都带了被试自评的效价和唤醒度分数采样率 256Hz做逐通道对齐非常方便。我自己的做法是优先用 CASE 跑通流程再用 DEAP 的外周信号做泛化验证。选通道时不要贪多。常见做法是选三到四路ECG/PPG 反映自主神经活动EDA 反映皮肤电导变化EMG 反映肌肉张力再加一路呼吸信号。路数越多预处理越复杂模态融合的收益在项目规模上并不总是正比。对一个“高分完整项目”来说四路信号足够写清楚多模态融合的思想又不会把精力耗在伪迹清理上。如果手头只有 DEAP外周信号里至少保留 EOG、EMG、EDA 三路再加上呼吸或脉搏。拿到原始数据后第一步不是提特征而是确认每个通道的采样点数是否一致、标签是否对齐。很多翻车现场是从 loadmat 之后数组维度对不上开始的。项目里的信号长度以视频实验为单位切片前先统一用滑动窗口把连续信号切成等长样本。2.2 滑动窗口切片与信号对齐一段可直接跑的 Python 预处理代码下面这段代码是我在 CASE 数据集上预处理时的标准流程。它把连续生理信号按固定窗口切分同时把每个窗口对应的效价/唤醒度标签一起输出后续特征提取直接吃这个函数的结果。import numpy as np from scipy import signal def sliding_window(data, label, fs256, win_sec5, step_sec1): 将多通道生理信号切成滑动窗口样本 :param data: ndarray, shape (n_channels, n_samples) :param label: ndarray, shape (n_samples,), 每个采样点上的标签 :param fs: 采样率, CASE 为 256Hz :param win_sec: 窗口长度秒 :param step_sec: 窗口步长秒 :return: windows, labels win_len int(fs * win_sec) # 5 秒窗口 1280 个采样点 step_len int(fs * step_sec) # 1 秒步长 256 个采样点 windows [] labels [] for start in range(0, data.shape[1] - win_len 1, step_len): end start win_len win data[:, start:end] # 取窗口中间时刻的标签作为该窗口标签避免标签突变 mid_idx start win_len // 2 windows.append(win) labels.append(label[mid_idx]) return np.array(windows), np.array(labels)这个函数的三个关键参数是win_sec、step_sec和标签对齐方式。窗口长度 5 秒是生理信号情感识别的常用值太短HRV 等指标无法稳定计算太长样本量下降情感状态在窗口内可能切换。步长 1 秒的作用是数据增广但代价是相邻窗口高度重叠。标签对齐我取“窗口中间时刻”的标签比取起始时刻更合理能覆盖窗口内真正的情感状态。CASE数据每个采样点都有标签所以可以直接这样切如果数据只有整段视频一个标签就把整段视频切块后统一打同一个标签。真实数据里还有一个容易被忽略的点np.array(windows)的 shape 是(n_windows, n_channels, win_len)后面特征提取时建议把通道维放到最后也就是(n_windows, win_len, n_channels)因为多数特征函数按最后一维处理信号。2.3 去基线漂移与去伪迹滤波器参数别照抄论文生理信号里最常见的两个噪声来源是基线漂移和工频干扰。基线漂移主要来自呼吸和电极移动频率通常在 0.5Hz 以下工频干扰在中国是 50Hz在部分公开数据集录制环境里可能是 60Hz。预处理时我习惯用scipy.signal.butter设计巴特沃斯滤波器而不是用中值滤波。中值滤波会破坏 HRV 的时域细节后面特征提取会吃亏。from scipy.signal import butter, filtfilt def apply_filter(data, fs256, low0.5, high45, order4): 带通滤波去除基线漂移和高频噪声 :param data: ndarray, shape (n_channels, n_samples) :param fs: 采样率 :param low: 高通截止频率, 0.5Hz 以下视为漂移 :param high: 低通截止频率, 45Hz 保留生理信号主要频段 b, a butter(order, [low, high], btypebandpass, fsfs) filtered filtfilt(b, a, data, axis-1) # 额外陷波 50Hz 工频中国电网环境 b_notch, a_notch butter(order, [49, 51], btypebandstop, fsfs) filtered filtfilt(b_notch, a_notch, filtered, axis-1) return filtered参数里最容易踩坑的是order。四阶听起来不大但filtfilt是零相位滤波等效阶数是原来的一倍高截止频率下会引入明显的边缘振铃。如果你在滤波后看到波形两端出现大幅摆动优先降阶而不是改截止频率。另外EMG 信号的频率范围是 10150Hz和 ECG 不一样不能共用上面这组参数EMG 通道我会单独用 10150Hz 带通不做 45Hz 低通。滤波顺序也有讲究先带通去漂移后陷波去工频顺序反了工频的残余会产生混叠后面频域特征会多出虚假峰值。预处理做到这里产出是一批 shape 为(n_windows, n_channels, win_len)的干净样本。下一步就是把每个样本变成一行特征向量。3. 特征提取与特征文件组织哪些特征真的对效价、唤醒度敏感3.1 手工特征清单时域、频域、非线性各挑什么多模态生理信号的情感识别在特征层面有两种流派手工特征加传统分类器或者把原始波形直接喂给深度学习模型。对“源码报告高分完整项目”来说手工特征加传统分类器更稳样本量小、可解释性强、报告里能写清楚每个特征的意义评审也挑不出逻辑漏洞。下面这张表是我常用的特征组合覆盖了时域、频域和非线性三类。信号通道特征类别具体特征对情感的意义ECG时域平均心率、SDNN、RMSSD、pNN50HRV 反映自主神经张力负性情绪下 SDNN 下降ECG频域LF、HF、LF/HF 比值交感/副交感平衡唤醒度高时 LF/HF 上升EDA时域SCL 均值、SCR 数量、SCR 幅值峰值唤醒度直接相关情绪唤起越强皮电响应越明显EDA频域EDA 频谱能量低频段能量与注意、紧张相关EMG时域RMS、MAV、过零率肌肉紧张度负面情绪和压力下 RMS 升高EMG频域中值频率、平均频率肌肉疲劳与持续紧张呼吸时域呼吸频率均值、呼吸频率变异系数情绪波动时呼吸节律变化非线性特征里最有区分度的是样本熵和近似熵。样本熵对生理信号的复杂度很敏感正性和负性情绪下的复杂度差异是显著的。但计算代价较高5 秒窗口的样本熵在纯 Python 里跑会非常慢建议用nolds库里的nolds.sampen并提前把信号降采样到 128Hz 再算。不要在同一窗口里堆十几个熵特征多重共线性会让分类器的权重解释变得不可信。3.2 用 Python 把多路特征拼成特征文件特征提取阶段的输出应该是一张二维表行是窗口样本列是特征。这张表要留好三列元数据subject_id、valence_label、arousal_label后面切分实验和分组交叉验证全靠它们。下面这段代码把多通道特征拼接成完整的特征矩阵并直接导出 CSV。import pandas as pd import numpy as np def extract_all_features(windows, fs256): 从滑动窗口样本中提取多模态特征 :param windows: ndarray, shape (n_windows, n_channels, win_len) :return: feature_df, 每一行对应一个窗口样本 rows [] for idx, win in enumerate(windows): ecg win[0] # 约定第 0 通道是 ECG eda win[1] # 第 1 通道是 EDA emg win[2] # 第 2 通道是 EMG resp win[3] # 第 3 通道是呼吸 row {} # ECG 时域 row[hr_mean] np.mean(60.0 / (np.diff(np.argmax(ecg)) / fs)) row[sdnn] np.std(np.diff(np.argmax(ecg)) / fs) # EDA 时域 row[eda_scl] np.mean(eda) row[scr_count] np.sum(np.diff(eda) 0.05) # 0.05 为最小变化阈值 # EMG 时域 row[emg_rms] np.sqrt(np.mean(emg ** 2)) # 呼吸频域能量用 Welch 法 f, pxx signal.welch(resp, fsfs) row[resp_power] np.trapz(pxx[f 0.5], f[f 0.5]) rows.append(row) return pd.DataFrame(rows) # 假设 windows 形状为 (n_windows, 4, 1280) features extract_all_features(windows) features[subject_id] subject_ids # 来自原始数据元信息 features[valence_label] valence_labels # 0/1 二分类 features[arousal_label] arousal_labels features.to_csv(multi_modal_features.csv, indexFalse)注意hr_mean那行代码里的np.argmax(ecg)这是用 R 波峰值近似心跳位置的简单做法。实际项目中建议用scipy.signal.find_peaks并设置distanceint(fs*0.3)防止把 T 波当 R 波。scr_count的阈值 0.05 是可以调的参数EDA 信号如果已经做了平滑阈值太大会漏掉微弱的皮肤电响应。特征文件命名和组织也是“高分项目”里容易被挑刺的点。我习惯按模态分列ecg_前缀放心电特征eda_前缀放皮电特征emg_前缀放肌电特征。这样报告里做单模态消融实验时只需要按前缀筛列不需要重新提取特征。特征文件里不要放原始时间序列文件会变得非常大而且训练时用不到。3.3 归一化陷阱整批数据归一化等于数据泄漏特征提取完成后最隐蔽的一个坑是归一化。常见错误是把所有样本合在一起算均值和标准差然后统一缩放。这在按样本随机切分的实验里看起来没问题但一旦按被试划分训练集和测试集测试集的信息已经通过均值混进了训练过程成绩会虚高 5 到 10 个百分点报告里写不干净。正确做法是先按被试分组把数据拆开再在训练集上拟合StandardScaler用同一个 scaler 去变换测试集。from sklearn.preprocessing import StandardScaler feature_cols [c for c in features.columns if not c.startswith((subject, valence, arousal))] X features[feature_cols].values y features[valence_label].values subjects features[subject_id].values # 按被试划分前 80% 被试为训练后 20% 为测试避免同被试样本跨集 unique_subjects np.unique(subjects) train_subjects unique_subjects[:int(0.8 * len(unique_subjects))] test_subjects unique_subjects[int(0.8 * len(unique_subjects)):] train_mask np.isin(subjects, train_subjects) test_mask np.isin(subjects, test_subjects) scaler StandardScaler() X_train scaler.fit_transform(X[train_mask]) X_test scaler.transform(X[test_mask]) y_train, y_test y[train_mask], y[test_mask]这段代码里最关键的是np.isin(subjects, train_subjects)做掩码选择而不是直接按行号切。因为滑动窗口生成的样本中同一个被试的窗口天然聚集在一起按行号切会把中间某一段被试的数据切开。按被试切分后再在训练集上fit归一化参数测试集只允许transform。如果unique_subjects数量太少比如只有 8 个被试按 8:2 切分后测试集只有 1 到 2 个被试性能波动会很大这种情况建议使用留一被试交叉验证Leave One Subject Out。4. 多模态融合与模型选型早融合、晚融合还是注意力4.1 三种模态融合方式适用场景和实现成本特征提取完成之后多模态“融合”体现在三个层面。早融合是把各路信号的特征直接拼接成一个长向量再送入分类器。实现最简单也是小样本项目里效果最稳的方案。晚融合是各路信号分别训练一个模型最后把概率平均或投票。这种方式对模态缺失更鲁棒比如某个通道的传感器坏了晚融合系统还能用剩下的模态工作。注意力融合则是给每个模态学一个权重动态加权实现复杂度和算力要求最高样本量低于几百时容易过拟合。对生理信号情感识别这种样本量通常只有几千甚至几百的任务我的建议是无脑先做早融合。它保留了特征间的相关性比如 ECG 频域特征和 EDA 时域特征在唤醒度上是有交互作用的拼接后模型能捕捉到这种组合。晚融合适合数据集大、模态间采样率差异明显的情况。注意力融合在项目的报告里可以写进“改进方向”不必在小样本上硬跑。早融合时特征维度会膨胀ECG 十几个特征、EDA 十几个特征、EMG 加呼吸又十几个拼起来动辄五六十维。这时候不要直接上线性 SVM而是先用基于树的模型做特征选择或者用带 L1 惩罚的逻辑回归筛一遍。下面这段代码展示了一个完整的早融合基线。4.2 用 sklearn 跑通一个可提交的强基线import numpy as np from sklearn.ensemble import RandomForestClassifier from sklearn.model_selection import cross_val_score from sklearn.model_selection import LeaveOneGroupOut from sklearn.pipeline import make_pipeline from sklearn.preprocessing import StandardScaler from sklearn.feature_selection import SelectFromModel from sklearn.linear_model import LogisticRegression # 特征矩阵 X标签 y被试分组 groups # 用留一被试交叉验证评估而不是随机 KFold groups features[subject_id].values logo LeaveOneGroupOut() # 先做 L1 逻辑回归做特征选择再训练随机森林 selector LogisticRegression(penaltyl1, solverliblinear, C0.1, max_iter1000) rf RandomForestClassifier(n_estimators200, max_depth6, random_state42) pipe make_pipeline( StandardScaler(), SelectFromModel(selector), rf ) scores cross_val_score(pipe, X, y, cvlogo, groupsgroups, scoringaccuracy) print(LOSO CV accuracy: {:.4f} (/- {:.4f}).format(scores.mean(), scores.std()))LeaveOneGroupOut是这个基线的灵魂。它保证每一折的训练集被试和测试集被试完全不相交评估结果才是真实泛化性能。随机森林的max_depth6是针对小样本设的不设限制的话树很容易长到把每个窗口背下来。C0.1是 L1 逻辑回归的正则强度越小特征选择越激进可以先从 0.1 起步用验证集调。特征选择这一步很重要早融合向量里如果混入大量无关特征随机森林的精度会明显下降而 L1 逻辑回归能从五六十维里筛出一二十维有效特征。跑完随机森林后记得看特征重要度排序。报告里写“ECG 的 LF/HF 比 EDA 特征更重要”这类结论时必须有这个排序输出支撑。特征选择结果本身也是报告里“模态融合有效性”的有力证据。4.3 训练、验证、测试怎么切按被试切不是按样本切很多项目翻车不是模型不行是数据划分方式让成绩虚高。生理信号情感识别里个体差异极大同一个人的生理基线、情感反应模式是高度相似的。如果随机按窗口切分同一个人同一段视频的相邻窗口会在训练集和测试集里同时出现模型实际是在“认人”而不是“认情绪”。这种设置下随便一个模型都能刷到 90% 以上但换个被试立刻掉到 60%。正确做法有三层最严格的是测试被试完全独立即训练阶段模型从未见过测试被试的任何样本其次是按视频划分同一段视频的窗口不跨集最宽松但也最容易被质疑的是按被试分组做交叉验证。报告里建议至少给出按被试留一的交叉验证结果如果做了“训练集、验证集、测试集”三层切分测试集被试必须完全独立于训练和验证。样本数量有限时按被试切分后训练集可能只剩七八个人的数据模型容易欠拟合。这时候用LeaveOneGroupOut比单次 8:2 划分更能稳定评估。最后一个细节随机森林和逻辑回归都支持class_weightbalanced如果正负样本不平衡建议开启报告里也要写明正负样本比例这是评审老师爱问的问题。5. 生理信号项目的六个常踩坑从数据划分到报告写作5.1 精度虚高随机切分导致的“认人”假象现象模型按 8:2 随机切分跑出 92% 准确率换成按被试留一验证后直接跌到 68%。原因同一被试的滑动窗口高度重叠相邻窗口特征几乎一样。随机切分时同一个人的样本同时出现在训练集和测试集等于让模型识别个体身份而不是情感状态。解决所有实验统一按被试分组划分评估必须使用LeaveOneGroupOut或至少按视频划分。报告里把两种切分方式的结果都写出来并解释为什么留一被试的成绩才是可信指标。5.2 特征文件里混入了用户标识列现象训练前忘记删掉subject_id列模型准确率异常高特征重要度排序里subject_id排第一。原因subject_id是离散编号树模型可以直接按编号分裂相当于给每个人建了专属分支。解决特征选择前显式排除所有含subject、label前缀的元数据列。在feature_cols的筛选中用startswith白名单只保留特征列。血的教训凡是加了“元数据”注释的列训练前一律删干净。5.3 EDA 特征对唤醒度有效但对效价几乎无效现象用 EDA 特征预测效价valence时准确率徘徊在随机水平改成预测唤醒度arousal后立刻提升到 80% 以上。原因皮电信号主要由交感神经驱动反映的是唤醒强度而不是情绪的正负方向。兴奋和愤怒都会引起皮电上升模型无法区分。解决效价预测优先用 ECG 的 HRV 特征和 EMG 的 RMS 特征EDA 特征主要用于唤醒度预测。报告里写结论时也必须分维度说明不要笼统说“多模态特征有效”。5.4 滤波器参数照搬论文结果波形两端完全变形现象滤波后的信号首尾出现大幅摆动RMS 特征失真模型性能下降明显。原因filtfilt是零相位滤波等效阶数翻倍高阶滤波在边界处振铃严重。部分论文用的滤波器参数是在特定采样率和数据长度下调出来的直接照搬不适用。解决滤波阶数降到 2 到 4 阶必要时用signal.savgol_filter替代带通滤波处理 EDA 这类缓变信号。滤波后做波形可视化检查不要只看特征数值。翻车之后我养成了一个习惯预处理后先打印信号的 min/max如果和原始数据量级差太多先怀疑滤波器。5.5 报告里只写最终精度不写数据划分和随机种子现象报告读起来像黑匣子只有“模型准确率 85%”没有数据划分细节、没有特征列表、没有超参数。原因把项目当成作业来写忽略了工程复现的基本要求。评审老师拿到的是源码和报告想复现却不知道怎么切数据、用什么随机种子。解决报告里单列一节“实验设置”写明窗口长度 5 秒、步长 1 秒、滤波器参数、数据划分方式、random_state42、每个模型的超参数。表格里列出每折交叉验证的准确率和标准差。代码仓库根目录放一个README.md写明运行顺序先跑preprocess.py再跑extract_features.py最后跑train.py。5.6 模态融合实验缺失单模态和多模态对比没做现象直接给出多模态的最终精度没有做单模态基线对比评审判定“无法证明多模态融合有效”。原因多模态融合是项目的主打卖点但很多人在代码里只跑了一个融合版本没有把单模态的结果记录作为对照。解决实验至少跑四组ECG 单独、EDA 单独、EMG 单独、三模态早融合。每组都用相同的特征选择和分类器保持划分和随机种子一致。报告里用一张对比表呈现四组的准确率和 F1融合版本只要稳定高于单模态项目的核心论点就站稳了。6. 让“源码报告”撑得起来可复现实验记录与最终交付规范一个项目真正让人觉得“高分”靠的不是准确率数字而是“换个人也能复现出这个结果”。我会在代码里固定全部随机种子包括numpy.random.seed(42)和random.seed(42)并把交叉验证的每一折预测结果导出成 CSV这样报告里的混淆矩阵不是事后补画的而是程序直接生成的。习惯上我会在项目根目录放一个run_all.py串联预处理、特征提取、训练评估三步避免交付后别人还要手动找脚本执行顺序。报告撰写我会按这个顺序排列引言写清楚为什么要做多模态而不是单模态数据处理章节交代数据集和信号通道选择方法章节给出特征表格和融合结构实验章节放单模态与多模态的对比表、按被试留一的交叉验证结果、混淆矩阵结论里列特征重要度排序和失败案例分析。最容易加分的一步是写“如果你复现时数据划分和我不同结果会有多大波动”——把这个波动范围跑出来写进去比任何修饰都有说服力。多模态生理信号情感识别这个方向做得顺不顺完全取决于预处理和评估是否严谨。把每一路信号的物理意义搞清楚把数据划分和随机种子固定下来再上模型剩下的就是参数调优和报告打磨。我自己的习惯是先看特征分布再做特征选择每调一个参数就记一次实验结果——这个习惯帮我避开了很多“改了参数到底有没有用”的玄学争论。希望这些实战里的细节能帮到你让你在这个项目上少走几步弯路。本文还有配套的精品资源点击获取