ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Python多模态情感识别:EEG/眼动/GSR融合与CLIP对比学习实战

Python多模态情感识别:EEG/眼动/GSR融合与CLIP对比学习实战 简介一套基于Python的多模态情感识别项目源码融合脑电EEG、眼动追踪与皮肤电GSR生理信号面向计算机/电子信息类毕业设计、情感计算研究者及人机交互开发者。整套源码覆盖信号预处理、特征提取与选择PCA/LDA、情感模型如Russell圆环构建以及SVM、随机森林、神经网络等分类器训练与评估流程并配有训练好的best_model.pth权重和模块化工程结构便于二次开发与扩展。整包为zip格式共38个文件包括14个Python源码、9张结果图表、6个XML工程配置、4个TXT数据/结果文件、1个YAML参数配置及1个预训练模型等压缩包大小8.99MB文档注释详尽适合需要完整参考实现、快速跑通实验或在此基础上开展毕业设计/科研实验的用户。目前已有89人学习下载代码组织清晰上手门槛适中可作为多模态情感识别方向的技术起点。1. 这个毕设项目到底拆了什么同样是看一段引发情绪的影片一个人手心出汗了脸上却没表情另一个人眼睛快速扫视却报告自己很平静。脑电、眼动、皮肤电这三类生理信号单独来看都会撒谎但放在一起就不会。这套基于 Python 的多模态情感识别源码包做的正是把 EEG、眼动追踪和 GSR 皮肤电反应融合成一套可训练的情感分类系统先对原始生理信号做滤波和特征提取再用三路编码器分别建模最后通过特征拼接和 CLIP 式对比损失把不同模态拉到同一个语义空间。源码包覆盖从RawData.py读数据、feature_extract.py提特征到Trainer.py训练、Tester.py出结果图的完整闭环multimodal_full/best_model.pth也已附带训练好的权重。适合做生理信号处理课题的研究生、准备毕业设计的本科生以及想快速复现一个基础多模态情感识别 baseline 的算法工程师。2. 数据管线从 train2.txt 到训练张量多模态情感识别项目最耗时间的不是模型而是把三种采样率不同、量纲不同、噪声特征也完全不同的信号整理成同一个 batch。源码包里的data/train2.txt、RawData.py、feature_extract.py、LoadFeatures.py和Dataset.py构成了一条完整的数据管线下面按数据流顺序拆开讲。2.1 RawData.py先看原始文件长什么样train2.txt不是 csv 也不是 json而是一个带表头的空格分隔文本每一行包含时间戳、被试编号、8 通道 EEG 电压值μV、眼动坐标与瞳孔直径、皮肤电导值μS和情感标签。这种格式常见于实验室自采数据好处是跨平台不需要额外依赖坏处是手写解析容易踩分隔符的坑。源码包里的RawData.py核心逻辑和下面的代码等价import pandas as pd def load_raw(pathdata/train2.txt): columns ( [stamp, subject] [feeg_{i} for i in range(8)] [gaze_x, gaze_y, pupil, gsr, label] ) df pd.read_csv(path, delim_whitespaceTrue, namescolumns) df df.dropna().sort_values(stamp).reset_index(dropTrue) return df这段代码用delim_whitespaceTrue按任意空白符切分比sep,或sep\t更省心因为手写导出的数据经常混用空格和制表符。dropna()用来丢弃采样瞬间丢失的 GSR 记录这类丢点在生理信号采集中非常常见。排序用stamp而不是索引是因为多台采集设备各自打时间戳文件里可能存在轻微乱序。需要说明的是subject列在这里不能只当作普通特征后面讲数据泄漏时会专门提到它。label是本项目的 4 分类目标愉悦、悲伤、愤怒、平静对应 Russell 效价-唤醒度模型的四个象限。2.2 feature_extract.py三类生理信号怎么提特征原始信号不能直接进网络原因有两个一是 EEG 存在 50Hz 工频干扰和肌电伪迹二是每个模态的原始维度差异太大直接拼接会让模型把注意力全放在方差最大的模态上。feature_extract.py对三类信号分别做了不同的处理。EEG 方面先用 0.5–40Hz 的 Butterworth 带通滤波去除基线漂移和工频干扰然后按频段提取 theta、alpha、beta 三个频段的功率谱能量眼动方面统计每个窗口内注视点均值、方差和瞳孔直径变化率GSR 方面用scipy.signal.find_peaks检测皮肤电导响应SCR的波峰数量、平均幅度和恢复时间。核心逻辑如下import numpy as np from scipy import signal def extract_eeg_feature(eeg_channel, fs256): b, a signal.butter(4, [0.5 / (fs / 2), 40 / (fs / 2)], btypeband) filtered signal.filtfilt(b, a, eeg_channel) spectrum np.fft.rfft(filtered * signal.windows.hamming(len(filtered))) power np.abs(spectrum) ** 2 alpha np.sum(power[8:13]) # 8-13Hz beta np.sum(power[13:30]) # 13-30Hz theta np.sum(power[4:8]) # 4-8Hz return np.array([alpha, beta, theta])filtfilt做的是零相位滤波会对信号正向和反向各滤一次能避免普通lfilter带来的相位偏移——这对后续按时间窗对齐很重要。这里的频段索引按fs256做了近似换算如果你的数据是 128Hz 采样alpha对应的索引要改成4:7直接套默认值会提取到错误的频带。源码包在config/config.yaml里暴露了sample_rate参数改数据集时第一件事就是核对它。GSR 的 SCR 检测需要注意基线漂移常见做法是先做 0.05Hz 高通滤波再找峰否则手指轻微移动造成的缓慢漂移会被误判成情绪唤起的皮肤电响应。process_graph.py中有对应的可视化逻辑确认特征正确后再进入下一步。2.3 Dataset.py / LoadFeatures.py样本窗口与模态对齐三种信号的采样率不一样这是多模态项目最核心的工程问题。假设 EEG 是 256Hz、眼动是 60Hz、GSR 是 8Hz如果直接按原始时间戳拼接样本维度会完全错位。Dataset.py的做法是固定一个 2 秒的时间窗把三种信号各自切窗后做等长特征化再在窗口维度上对齐import torch from torch.utils.data import Dataset class MultimodalDataset(Dataset): def __init__(self, feature_dict, labels, window_size2, sample_rate256): self.eeg torch.tensor(feature_dict[eeg], dtypetorch.float32) self.eye torch.tensor(feature_dict[eye], dtypetorch.float32) self.gsr torch.tensor(feature_dict[gsr], dtypetorch.float32) self.labels torch.tensor(labels, dtypetorch.long) self.window window_size * sample_rate def __len__(self): return len(self.labels) def __getitem__(self, idx): # 每个样本是固定窗口内的统计特征 eeg_win self.eeg[idx] eye_win self.eye[idx] gsr_win self.gsr[idx] return {eeg: eeg_win, eye: eye_win, gsr: gsr_win}, self.labels[idx]这里返回的是一个字典而不是元组是为了后面模型前向传播时直接按模态名取数据避免写x[0]、x[1]这种容易混淆顺序的写法。窗口大小在 yaml 里配置默认 2 秒的原因是这个时长既能包含完整的 SCR 响应周期又能保证 EEG 频带分辨率足够。LoadFeatures.py负责把feature_extract.py的输出缓存成内存中的字典。有个细节值得注意特征缓存一定要在预处理层完成而不是每次__getitem__都重新算一遍。这个项目的训练数据量不大但如果扩展到长时记录重复提取会成为训练瓶颈。3. 模型设计MultimodalModel 的编码器与 MultimodalCLIP 的对比损失数据管线跑通后核心问题变成三类特征怎么融合。源码包给了两套模型MultimodalModel.py是常规的编码器加融合分类头MultimodalCLIP.py则是带跨模态对比学习的变体。这两套都能单独训练区别在于后者多了一个对齐损失收敛更慢但特征语义更强。3.1 MultimodalModel.py 的三路编码器与融合三类信号的模态差异决定了编码器的选择EEG 是时序性强、空间结构弱的信号用 1D CNN 提取局部模式比 LSTM 更稳眼动和 GSR 的特征维度低、统计性强用 MLP 就足够GSR 的响应有明确的上升和恢复阶段Transformer 能捕捉这种长程依赖。MultimodalModel.py的大致结构如下import torch.nn as nn class EEGEncoder(nn.Module): def __init__(self, in_channels8, hidden256): super().__init__() self.conv nn.Sequential( nn.Conv1d(in_channels, 32, kernel_size5, stride2, padding2), nn.BatchNorm1d(32), nn.ReLU(), nn.AdaptiveAvgPool1d(64), ) self.proj nn.Linear(32 * 64, hidden) def forward(self, x): return self.proj(self.conv(x).flatten(1))in_channels8对应数据里的 8 通道 EEG 电极。AdaptiveAvgPool1d(64)的作用是把不定长的卷积输出变成固定长度这样即使换数据集后窗口长度变了模型结构也不用改。三路编码器分别输出 256 维向量后融合层用多头注意力计算模态间的权重而不是简单 concat——简单 concat 的问题是模型容易学会忽略某个模态注意力融合可以保留模态间的交互信息。3.2 MultimodalCLIP.py 为什么要加一个 CLIP 式对比损失MultimodalCLIP.py借鉴了 CLIP 的对比学习思路不再让模型直接预测标签而是先让 EEG 特征和眼动GSR 特征在语义空间中对齐。直觉是一段影片引发的皮电升高和脑电 alpha 波抑制描述的是同一个情绪状态两个模态的嵌入向量应该在方向上接近而不同情绪的嵌入应该互相远离。对比损失如下def contrastive_loss(eeg_emb, physio_emb, temperature0.07, labelsNone): logits torch.matmul(eeg_emb, physio_emb.T) / temperature if labels is not None: positive_mask labels.unsqueeze(0) labels.unsqueeze(1) logits logits.masked_fill(~positive_mask, -1e9) else: logits logits target torch.arange(logits.size(0), devicelogits.device) loss ( nn.CrossEntropyLoss()(logits, target) nn.CrossEntropyLoss()(logits.T, target) ) / 2 return losstemperature是 softmax 的温度系数默认 0.07 是 CLIP 论文里的取值。温度越小logits 分布越尖锐对负样本的惩罚越强。如果类别信息可用这里通过masked_fill把同一类别的样本也当成正样本比只用对角线作为正样本更稳。实际训练时这个对比损失和分类损失按1:0.5加权分类损失保证可判别性对比损失保证模态对齐。3.3 消融实验看融合到底有没有用项目在results/results.txt里记录了各实验配置的输出按这套数据跑出来的典型结果如下输入模态模型结构测试准确率仅 EEGCNN 编码器 MLP79.3%仅眼动MLP72.6%仅 GSR1D CNN Transformer68.4%EEG 眼动 GSR特征拼接 分类头84.1%EEG 眼动 GSR注意力融合 CLIP 对比损失88.2%单模态里 EEG 效果最好符合认知神经科学的预期脑电直接反映中枢神经活动而眼动和 GSR 受外周调控影响个体差异更大。两个值得注意的点一是特征拼接只比单模态高不到 5 个百分点说明简单拼接确实容易让模型学会忽略弱模态二是加 CLIP 对齐后涨了 4 个百分点说明对比损失让 GSR 这种弱模态也参与到了特征学习中。4. 训练配置与结果文件解读config.yaml、Trainer.py、Tester.py模型定义只是骨架真正的调参工作集中在配置和训练循环里。源码包的config/config.yaml、Trainer.py、Tester.py和process_graph.py构成了从训练到可视化的一整套闭环。4.1 config.yaml一份能跑通的完整配置项目把可调参数全部收敛在 yaml 里改模型结构不需要动 Python 代码。这份配置是开箱即用的data: raw_path: data/train2.txt window_sec: 2 sample_rate: 256 feature_cache: multimodal_full/cache.pkl model: eeg_encoder: cnn1d eye_encoder: mlp gsr_encoder: transformer hidden_dim: 256 fusion: attention use_contrastive: true temperature: 0.07 train: batch_size: 32 epochs: 60 lr: 0.001 weight_decay: 0.0001 loss_weights: ce: 1.0 contrastive: 0.5 early_stop_patience: 8 save_dir: multimodal_fullloss_weights里对比损失的权重是全局最敏感的参数。设成 1.0 时模型会把大量容量花在对齐上分类准确率反而下降设成 0.1 时对齐效果太弱和普通拼接没区别。0.5 是个人常用的起始值。temperature和batch_size有联动关系batch 越小每个 batch 内的负样本越少温度需要适当调高如 0.1否则对比损失容易不收敛。4.2 Trainer.py早停、学习率与 best_model.pth 保存逻辑训练循环里最值得学习的是早停和模型保存策略。Trainer.py的实现思路是每个 epoch 结束都评估一次验证集只有当验证准确率创新高时才覆盖保存best_model.pth连续 8 个 epoch 没有提升就终止训练best_acc 0.0 patience_counter 0 for epoch in range(max_epochs): train_loss train_one_epoch(model, train_loader, optimizer, scheduler) val_acc evaluate(model, val_loader) if val_acc best_acc: best_acc val_acc torch.save(model.state_dict(), multimodal_full/best_model.pth) patience_counter 0 else: patience_counter 1 if patience_counter cfg[train][early_stop_patience]: print(fearly stop at epoch {epoch}) breaktorch.save只保存state_dict而不是整个模型对象好处是不依赖模型类定义的路径加载时自由度更大。Tester.py加载权重时要用同样的模型结构先实例化再load_state_dict很多初学着在这里报missing keys或unexpected keys原因多半是修改了hidden_dim但用了旧的权重文件。训练流程建议放到 Python 虚拟环境里跑。在 VSCode 或 PyCharm 中配置 Python 解释器时优先用 conda 建一个 Python 3.9 的环境避免系统 Python 的版本冲突命令行启动训练用python main.py --config config/config.yaml即可main.py里已经通过argparse把配置文件的路径暴露成了参数。4.3 Tester.py 与结果文件训练完怎么看图找问题训练结束后Tester.py会在测试集上做一次完整推理输出results.txt同时由process_graph.py生成 4 张图。项目里对应的文件映射关系如下输出文件内容怎么读Figure_17.png训练和验证损失曲线验证损失不降说明过拟合验证损失震荡说明学习率过大Figure_18.png测试集混淆矩阵看哪些类别互相混淆通常愤怒和平静最容易混Figure_19.png各情绪类别准确率柱状图判断模型是不是只对某个类别有效Figure_20.png三模态特征 T-SNE 可视化看不同类别在特征空间是否可分results/results.txt里除准确率外还记录了每类样本的召回率和 F1如果整体准确率还行但某个类别召回率偏低问题通常出在数据不平衡而不是模型结构上。此时优先检查train2.txt里各标签的样本数而不是急着换损失函数。5. 实测排错记录本地上手跑这个源码包的五个坑最后这部分是实际运行这套代码时最常遇到的五个问题按遇到频率排序。5.1 先检查 Python 环境与工作目录刚拿到源码包直接双击main.py大概率报ModuleNotFoundError。项目依赖numpy、scipy、torch、pandas、yaml、matplotlib建议用 conda 创建新环境后一次性安装。VSCode 打开项目时确认右下角解释器选的是虚拟环境否则会出现代码能跑但无法命中断点的问题这通常不是源码问题而是工作目录或解释器路径设置不对。5.2 模态尺寸对齐Dataset.py返回的三个特征张量第一维必须一致否则模型 forward 时第一个 batch 就报维度错误。排查方法是print(loader.dataset[0][eeg].shape)三个值不一样就检查窗口切片逻辑。GSR 采样率低2 秒窗口只产生约 16 个点特征提取时要注意窗口边界不要越界。5.3 数据泄漏这是情感识别项目最容易犯、也最隐蔽的错误。如果按整个 session 划分训练集和测试集同一个被试的相邻样本会同时出现在两边导致测试准确率虚高。正确做法是RawData.py读入数据后按subject列分组划分数据train_test_split(test_size0.2, stratifysubject_ids)保证同一个被试不会跨集合。5.4 训练不收敛训练曲线震荡但没有下降趋势时先调学习率从 0.001 降到 0.0003仍然无效就检查温度系数。对比损失在temperature0.07时梯度容易出现尖峰尤其 batch size 只有 16 时。经验值是 batch size 每减半温度调大到原来的 1.2 倍。5.5 先跑单模态再开融合新手最容易直接把config.yaml里use_contrastive设为true硬训结果损失下降很慢。推荐的复现顺序是先跑纯 EEG 单模态确认数据管线和训练循环没问题再开三模态拼接最后才加 CLIP 对比损失。这样每一步的错误都有明确归属results/results.txt里每一步的准确率变化也能作为排错依据。本文还有配套的精品资源点击获取
返回列表