
简介面向计算机相关专业毕业设计、课程设计与期末大作业场景这份Python多模态生理信号情感识别项目提供完整可运行源码与配套说明文档。项目基于EEG、EOG等多模态生理信号覆盖数据预处理、滑动窗口构造、CNN/RNN建模、训练评估等完整流程适合需要快速搭建情感识别系统或理解多模态融合思路的学习者。资源共39个文件包含6名受试者的原始mat生理数据、预处理后的pkl数据集、Python脚本与Jupyter Notebook以及训练过程与结果的可视化图片压缩包约548MB目录按原始数据、预处理数据及代码划分便于复现实验。已有150人学习浏览项目出自高分毕业设计代码结构清晰可执行脚本与说明文档能够帮助读者降低复现门槛并可用于课程设计或进一步的情感计算研究。1. 多模态生理信号情感识别为什么说前处理比模型更决定天花板用 Python 做多模态生理信号情感识别卡住大多数人的不是模型选型而是不知道 EEG、ECG、EDA 这些信号怎么对齐、怎么切窗、特征怎么融合才不丢信息。这个方向在情绪计算、人机交互、驾驶疲劳监测里都有落地场景但很多人拿着公开数据集跑了几个月准确率始终卡在 70% 上下问题往往出在数据流水线上而不是网络结构上。这篇文章从数据组织、特征提取、融合策略到评估陷阱把一套可复现的完整流程拆开讲。适合两类人一是做毕业设计或课程项目、需要从零搭一套情感识别系统的学生二是想评估多模态方案在自己业务场景里投入产出比的工程师。如果你已经跑通过单模态比如只用脑电这篇文章能帮你补上多模态融合和跨被试评估的关键拼图避开常见的隐性翻车点。2. 数据和信号预处理先搞清三种生理信号的脾气2.1 常用数据集选择DEAP 与 WESAD 的适用边界多模态生理信号情感识别绕不开公开数据集。最常用的两个是 DEAP 和 WESAD但它们的采集设置差异很大直接影响你的预处理代码怎么写。DEAP 是脑电为主的方案32 通道 EEG 加上 8 路外周信号EOG、EMG、GSR、呼吸等40 个 1 分钟视频刺激32 名被试。每个 trial 前有 3 秒基线评分维度是 valence、arousal、dominance、liking按 1-9 连续打分。这个数据适合做二维情感模型valence-arousal分类但要注意EEG 通道多计算量大预处理阶段就要规划好内存。WESAD 则是穿戴式设备的典型采集的是 ECG、EMG、EDA、体温、呼吸3 类受试状态baseline、stress、amusement。它的采样率和通道数都比 DEAP 少但胜在贴近真实可穿戴场景数据流干净适合做二分类或三分类压力检测。如果你目标是落地到智能手表、手环这类低功耗能设备WESAD 的参考价值比 DEAP 高得多。两个数据集配套的标签协议不同代码里要把标签映射单独抽出来做配置项不要写死在脚本里。我一般会在项目根目录放一个config.yaml统一管理数据集路径、采样率、标签映射和窗口参数后面每个模块都从这里读配置避免改参数时到处翻代码。2.2 按数据集做统一的信号清洗与切窗流程拿到原始数据后第一件事不是提取特征而是做信号清洗。这段流程写在一个preprocess.py里保证任何输入数据都走同一条流水线。import numpy as np from scipy import signal def preprocess_pipeline(data, fs, bandsNone): 统一预处理流程去基线、滤波、切窗、z-score。 Args: data: 原始信号shape 为 (n_channels, n_samples) fs: 采样率 bands: 频带边界列表默认是 EEG 常用频带 Returns: cleaned: 滤波且去除基线后的信号 windows: 切窗后的数据shape 为 (n_windows, n_channels, win_len) if bands is None: bands [0.5, 4, 8, 13, 30, 45] # 0.5-45Hz 覆盖 EEG 主要节律 # 1. 去基线减掉每个通道前 3 秒的均值 baseline_len int(fs * 3) baseline_mean np.mean(data[:, :baseline_len], axis1, keepdimsTrue) detrended data - baseline_mean # 2. 带通滤波抑制工频和直流漂移 sos signal.butter(4, [bands[0], bands[-1]], btypebandpass, fsfs, outputsos) filtered signal.sosfiltfilt(sos, detrended, axis1) # 3. 切窗4 秒窗口2 秒步长50% 重叠 win_len int(fs * 4) step int(fs * 2) windows [] for start in range(0, filtered.shape[1] - win_len 1, step): windows.append(filtered[:, start:start win_len]) return filtered, np.array(windows)这段代码的核心参数有两个。第一个是基线长度DEAP 官方文档明确写的是 3 秒基线直接照抄没问题如果用了 WESAD 或其自采数据先看采集协议里 baseline 段到底有多长写死 3 秒会带偏后续所有特征。第二个是窗口重叠率50% 重叠是主流做法它既能让相邻窗口平滑过渡又不会把重复信息放大太多。注意sosfiltfilt是零相位滤波输入信号会被正向反向各过一遍所以输出没有相位偏移。代价是计算量翻倍但对离线分析和训练来说完全值得。如果以后要部署到实时系统可以换sosfilt并接受一定相位延迟。2.3 标签对齐一个窗口标一个类别把时间戳搞错清洗完信号后标签对齐是出问题最多的环节。DEAP 里每个视频是 60 秒评分是看完后打的WESAD 里标签是逐秒标注的。统一的做法是把连续标签按时间戳切到和窗口相同的边界一个窗口只保留一个标签。def align_labels(windows, labels, fs, win_len, step): 把连续标签对齐到每个窗口。 对每个窗口的起始时间点取该点在标签序列上的值作为窗口标签。 若标签序列采样率与信号不一致先重采样到信号采样率。 n_windows len(windows) window_labels [] for i in range(n_windows): # 窗口中心点对应的时间索引 center int((i * step win_len / 2) * fs) clipped min(center, len(labels) - 1) # 如果标签是连续值这里可以做阈值映射 window_labels.append(labels[clipped]) return np.array(window_labels)这段代码里用窗口中心点而不是起点去对齐标签是因为在连续标注的数据集中窗口边界处标签变化频繁取起点容易把前一秒的标签带进当前窗口。取中心的本质是假设窗口内的情感状态相对稳定中心处最能代表整个窗口的属性。在实际项目里我遇到过标签序列本身采样率只有 4Hz、信号是 128Hz 的情况这时候要先对标签做最近邻上采样否则重采样会引入虚假的过渡标签。这里有个关键点如果做的是回归而非分类比如预测 valence 的连续分数窗口中心对齐策略同样适用。但要额外做标签平滑比如对窗口标签做 3 点移动平均避免相邻窗口标签跳变太剧烈影响模型收敛。3. 特征工程模态特征怎么提、怎么拼才不丢信息3.1 EEG 的频域特征与 DE 差分熵选得有依据EEG 特征的选择决定了整体效果的上限。传统做法是提取五个频带的功率谱密度PSD再加上统计特征但近年在情感识别里效果比较稳定的做法是提取微分熵Differential EntropyDE。经验法则是DE 特征在高密度脑电方案下比单纯功率谱特征在分类任务上普遍有 5%-10% 的提升因为 DE 能同时刻画信号的强度和不确定性而功率谱只刻画能量分布。特征提取代码如下。def extract_de_feature(windows, fs, bands): 从 EEG 窗口提取五个频带的微分熵特征。 微分熵对高斯分布信号等于 ln(sigma * sqrt(2*pi*e)) 所以关键是把每频带内的信号方差算出来。 Returns: de_feat: shape (n_windows, n_channels * n_bands) from scipy.signal import butter, sosfiltfilt feature_list [] for w in windows: win_feats [] for band in bands: lo, hi band sos butter(4, [lo, hi], btypebandpass, fsfs, outputsos) filtered sosfiltfilt(sos, w, axis1) # 对每个通道计算对数方差等价于微分熵 log_var np.log(np.var(filtered, axis1) 1e-8) win_feats.append(log_var) feature_list.append(np.concatenate(win_feats)) return np.array(feature_list)需要注意1e-8的作用。如果不加这一个小常数方差为 0 的通道会把log变成负无穷直接污染整条特征向量。加一个小常数的本质是数值保护它不改变特征相对大小但避免了 NaN。另一个容易忽略的点是滤波阶数这里的 4 阶 Butterworth 是做 EEG 频带分解的常见选项过高的阶数会让通带边缘振铃过低则频带分离不干净。3.2 外周信号统计特征GSR 的起伏速度比均值更关键外周信号GSR、ECG、呼吸的特征套路和 EEG 完全不同。GSR 的关键是皮肤电导的相位变化也就是皮肤电导反应SCR的频率和幅度ECG 则要关注心率变异性HRV的时频指标比如相邻 R-R 间期的标准差SDNN和低频高频功率比。下面这段代码提取 GSR 的时域和频域特征重点放在变化的速率上。def extract_peripheral_features(windows, fs): 提取 GSR / ECG 外周信号的统计特征。 包含三组 - 时域均值、方差、一阶差分均值、二阶差分均值 - 频域主频、频带能量占比 - 形态局部极大值个数对应 SCR 峰值数 features [] for w in windows: win_ft [] for ch in w: diff1 np.diff(ch) diff2 np.diff(diff1) # 时域基础统计 win_ft.extend([ np.mean(ch), np.std(ch), np.mean(np.abs(diff1)), np.mean(np.abs(diff2)), ]) # 频域取功率谱峰值作为主频 fft_vals np.fft.rfft(ch) freqs np.fft.rfftfreq(len(ch), d1.0/fs) power np.abs(fft_vals) ** 2 main_freq freqs[np.argmax(power[1:]) 1] # 跳过直流 win_ft.append(main_freq) # SCR 峰值个数一阶差分过零且幅度超阈值 threshold 0.1 * np.std(diff1) peaks np.sum((np.abs(diff1[:-1]) threshold) (diff1[:-1] * diff1[1:] 0)) win_ft.append(peaks) features.append(win_ft) return np.array(features)这段代码的要点是peaks的计算方式。diff1[:-1] * diff1[1:] 0是找一阶差分变号的位置也就是原始信号的局部极值点再加上幅度阈值过滤就是一次明显的皮肤电导反应。阈值0.1 * np.std(diff1)是经验值它跟随信号本身的波动水平自适应避免固定阈值在静息态和紧张态之间失效。外周信号的特征不宜堆太多每个通道选 10-15 个特征即可。外模态特征数量过多时和 EEG 特征拼接后容易出现维度灾难。3.3 特征级融合 vs 决策级融合代价不同效果不同融合策略决定了多模态的价值能不能发挥出来。三种常见方案数据级融合把原始信号拼一起、特征级融合提取特征后拼接、决策级融合每个模态独立出预测再投票。数据级融合在这类项目里实现难度最大因为不同信号的采样率不一样EEG 128Hz、GSR 可能只有 8Hz直接拼接会造成严重错位。特征级融合是性价比最高的方案这也是本文代码走的路线。决策级融合适合两个模态的强分类器各自表现都很好时但缺点是没有交互信息。如果时间有限先把特征级融合做到位。一个简单的实现是用np.hstack把 EEG 和外周特征拼起来再做一个标准化。def fuse_features(de_feat, peri_feat): 特征级融合直接拼接 标准化。 注意必须先用训练集的均值和标准差做 fit再应用到验证集/测试集 禁止对全量数据一次性标准化否则会造成标签泄漏。 fused np.hstack([de_feat, peri_feat]) # 这里只演示 shape 检查标准化请放到训练循环里做 assert fused.ndim 2, 特征应该是 2D 数组 return fused这段代码演示的是融合后的 shape 形态真正的标准化要放到训练集上做。很多人在这个位置翻车对全量数据一起StandardScaler.fit_transform导致验证集的信息提前混入训练过程最后评估准确率虚高。3.4 特征标准化不按被试划分的标准化是错误的标准化环节最容易出现的隐性错误就是全局标准化。正确做法是只拿训练集的均值和方差同一个被试的数据要么全在训练、要么全在测试不能跨被试混进统计量。from sklearn.preprocessing import StandardScaler def fit_scaler_on_train(X_train): 只在训练集上拟合标准化器。返回的 scaler 应用到测试集。 scaler StandardScaler() scaler.fit(X_train) return scaler def apply_scaler(scaler, X): 用已拟合的 scaler 转换数据。 return scaler.transform(X)这段代码的逻辑很简单但背后的意义很大。假设你有 32 个被试的数据按 80/20 划分时如果随机分配样本同一个被试的窗口会同时出现在训练集和测试集里。表面上看准确率高了一个档次实际是模型在记忆被试特征而不是泛化。正确的划分方式是按被试划分比如训练集放 26 个被试、测试集放 6 个这种留出法才有实际参考价值。4. 模型与训练用轻量特征 注意力机制搭高性价比分类器4.1 为什么不用复杂深度模型数据量撑不住很多初学者一上来就想用 Transformer 或者大卷积网络但在 DEAP 这种规模的数据集上32 被试 × 40 视频单样本窗口数量也就几万级别深层模型很容易过拟合。更稳妥的路线是特征级融合 轻量模型XGBoost、随机森林、或浅层 MLP作为基线再尝试加一层 attention 的 BiLSTM。常见做法是先跑基线把效果压榨到极限再考虑深度模型。如果浅层模型在验证集上都能到 85% 以上深度模型带来的收益有限反而徒增调参成本。用 MLP 做基线的优势在于它可以吃进特征级融合后的向量训练快、可解释性强、模型文件小。对后续要部署到嵌入式设备或者写进报告也友好。4.2 BiLSTM Attention 结构先解释为什么有效再加代码在生理信号多模态任务中BiLSTM 能捕捉窗口之间的时间依赖比如连续几个窗口的 GSR 上升趋势是有价值的情感线索。Attention 层的作用是让模型学会自动给重要时间步更高的权重而不是把整段时间平均对待。实现上用 PyTorch 并不复杂。import torch import torch.nn as nn class BiLSTMAttention(nn.Module): 特征序列分类输入 shape (batch, seq_len, feat_dim)。 def __init__(self, feat_dim, hidden_dim64, num_layers1, num_classes2): super().__init__() self.lstm nn.LSTM( input_sizefeat_dim, hidden_sizehidden_dim, num_layersnum_layers, batch_firstTrue, bidirectionalTrue ) # 注意力得分 self.attn nn.Linear(hidden_dim * 2, 1) # 双向所以乘 2 self.fc nn.Linear(hidden_dim * 2, num_classes) self.dropout nn.Dropout(0.3) def forward(self, x): out, _ self.lstm(x) # (batch, seq, hidden*2) attn_weights torch.softmax(self.attn(out), dim1) # 归一化权重 attended torch.sum(attn_weights * out, dim1) # 加权求和 return self.fc(self.dropout(attended))attn层把每个时间步的输出映射成一个标量分数softmax后得到归一化权重再与out逐元素相乘后求和。这段代码是对时间维度做注意力加权得到的是一个固定长度的向量可以接全连接分类。dropout0.3是经验值如果训练集比较小可以加大到 0.5避免过拟合。4.3 训练循环与超参数80 个 epoch 内见分晓训练参数按以下配置起步后续可以再调。优化器用 Adam初始学习率 1e-3batch size 取 32损失函数用交叉熵。如果不是特别大的数据集80 个 epoch 左右就能看到收敛趋势。def train_model(model, train_loader, val_loader, epochs80, lr1e-3): optimizer torch.optim.Adam(model.parameters(), lrlr) criterion nn.CrossEntropyLoss() device torch.device(cuda if torch.cuda.is_available() else cpu) model.to(device) for epoch in range(epochs): model.train() train_loss 0.0 for x_batch, y_batch in train_loader: x_batch, y_batch x_batch.to(device), y_batch.to(device) optimizer.zero_grad() output model(x_batch) loss criterion(output, y_batch) loss.backward() optimizer.step() train_loss loss.item() # 每个 epoch 结束后在验证集上跑一次 model.eval() val_acc 0.0 total 0 with torch.no_grad(): for x_val, y_val in val_loader: x_val, y_val x_val.to(device), y_val.to(device) pred model(x_val) val_acc (pred.argmax(1) y_val).sum().item() total y_val.size(0) if (epoch 1) % 20 0: print(fEpoch {epoch1}/{epochs}, Loss: {train_loss:.4f}, fVal Acc: {val_acc/total:.4f}) return model这段训练循环没有加学习率调度先跑通再说。如果验证集在 40 个 epoch 后震荡不收敛可以把lr降到 3e-4或者加入ReduceLROnPlateau验证集损失连续 5 个 epoch 不降就衰减到 0.1 倍。有个参数容易被忽略bidirectionalTrue时LSTM 输出维度翻倍所以注意力层的输入维度必须写成hidden_dim * 2。改 hidden_dim 时这里容易漏改轻则报 shape 错重则静默训练出垃圾模型。4.4 评估方式按被试留出法而非随机划分前面提到过按被试划分是对的评估方式。这里给完整的划分代码。from sklearn.model_selection import LeaveOneGroupOut def subject_wise_split(features, labels, subject_ids): 按被试划分训练集和测试集group 是 subject_id。 这里以 LOOCV 为例也可以换成 GroupKFold。 logo LeaveOneGroupOut() for train_idx, test_idx in logo.split(features, labels, groupssubject_ids): yield train_idx, test_idx用LeaveOneGroupOut的好处是公平评估每个被试的泛化能力最后报告平均准确率和标准差。如果只留一个被试做测试每个被试要跑一次训练32 个被试就是 32 次时间成本高但结果最有说服力。项目报告里写清楚用了几折、被试划分方式比堆一堆准确率数字更有说服力。5. 避坑指南实验结果虚高的四个典型原因5.1 随机打乱数据导致跨被试评估失真现象测试准确率高达 95%一换被试就跌到 60%。原因划分数据时用了train_test_split但没有设置groups参数同一个被试的窗口被分到训练集和测试集两侧。模型看到的是同一个人的生理特征分布本质上是在做个体识别不是情感识别。解决使用按被试分组的GroupKFold或LeaveOneGroupOut并且对训练集做标准化后用同一个 scaler 变换测试集。5.2 全量数据标准化导致标签泄漏现象验证集指标稳定提交到新数据上准确率骤降。原因在全量数据上fit_transform测试集统计量已经混进训练过程模型的输出被“剧透”了。解决标准化器只在训练集上fit测试集只transform。并且把这一行代码写进注释里项目报告里也要描述这是多发翻车点。5.3 DEAP 数据里前 3 秒基线没扣除干净现象特征值整体偏大分类结果不稳定。原因DEAP 每个 trail 的前 3 秒是静息基线信号整体有一个 DC 偏移。如果不扣除EEG 功率谱特征会被基线污染尤其是低频段的能量。解决窗口切分前先减掉基线均值并检查减基线后的信号是否大致以 0 为中心方法是打印每个通道的均值。5.4 类别不平衡下只用准确率评估现象arousal 二分类准确率 88%但召回率只有 40%。原因标签本身不平衡比如 valence 打分 5 分以上和以下的数量比接近 3:1模型偏向预测多数类。解决报告里至少给出 accuracy、macro-F1、每个类别的 precision 和 recall。如果类别不平衡明显在损失函数里加 class weight 或做采样均衡。注意多模态项目报告里这三个坑的真实性极高。写报告时可以贴出现象和对策但不要编造不存在的数据来源或把修复后的准确率夸大成满分。6. 从跑通到可信消融实验与特征重要性验证技巧一套流程跑通并拿到报表后还需要做两件事消融实验和特征重要性分析。前者证明多模态确实比单模态有效后者让特征改动更聚焦。消融实验的矩阵可以这样设计仅 EEG、仅外周、EEG外周特征级、EEG外周决策级四组对照跑同一评估协议。这么做既验证了多模态的增益是真实存在的也能定位主要贡献模态在谁身上。特征重要性分析在浅层模型里用permutation_importance就很直观不需要写 SHAP 代码。from sklearn.inspection import permutation_importance def analyze_feature_importance(model, X_val, y_val): 对验证集做排列重要性分析。 关键参数 - scoring: 与训练时的评估指标保持一致 - n_repeats: 建议 10太少会导致稳定性差 result permutation_importance( model, X_val, y_val, scoringaccuracy, n_repeats10, random_state42 ) return result.importances_meann_repeats10的含义是把每个特征列随机打乱 10 次计算指标下降的平均值。特征被打乱后指标跌得越多说明该特征对预测的贡献越大。对 EEG 来说通常顶区、颞区的 beta 和 gamma 频带 DE 特征贡献靠前对外周信号来说GSR 的峰值数和 HRV 的低频功率占比往往是强特征。最后说一个习惯每次调参或改特征后把实验配置和对应的验证指标写进一个简单的experiments.csv几行代码就能记录。靠脑子记参数很快就乱了这个表能让你在写报告或复现时省下大量时间。这套流程从数据处理到消融实验走一遍投入的时间是值得的因为只有多模态之间的贡献对比清楚了项目才不是“跑完就忘”。希望帮到你。本文还有配套的精品资源点击获取