简介:一套基于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(path="data/train2.txt"): columns = ( ["stamp", "subject"] + [f"eeg_{i}" for i in range(8)] + ["gaze_x", "gaze_y", "pupil", "gsr", "label"] ) df = pd.read_csv(path, delim_whitespace=True, names=columns) df = df.dropna().sort_values("stamp").reset_index(drop=True) return df这段代码用delim_whitespace=True按任意空白符切分,比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, fs=256): b, a = signal.butter(4, [0.5 / (fs / 2), 40 / (fs / 2)], btype="band") 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带来的相位偏移——这对后续按时间窗对齐很重要。这里的频段索引按fs=256做了近似换算,如果你的数据是 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_size=2, sample_rate=256): self.eeg = torch.tensor(feature_dict["eeg"], dtype=torch.float32) self.eye = torch.tensor(feature_dict["eye"], dtype=torch.float32) self.gsr = torch.tensor(feature_dict["gsr"], dtype=torch.float32) self.labels = torch.tensor(labels, dtype=torch.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_channels=8, hidden=256): super().__init__() self.conv = nn.Sequential( nn.Conv1d(in_channels, 32, kernel_size=5, stride=2, padding=2), 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_channels=8对应数据里的 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, temperature=0.07, labels=None): 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), device=logits.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里记录了各实验配置的输出,按这套数据跑出来的典型结果如下:
| 输入模态 | 模型结构 | 测试准确率 |
|---|---|---|
| 仅 EEG | CNN 编码器 + MLP | 79.3% |
| 仅眼动 | MLP | 72.6% |
| 仅 GSR | 1D CNN + Transformer | 68.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(f"early 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_size=0.2, stratify=subject_ids)保证同一个被试不会跨集合。
5.4 训练不收敛
训练曲线震荡但没有下降趋势时,先调学习率,从 0.001 降到 0.0003;仍然无效就检查温度系数。对比损失在temperature=0.07时梯度容易出现尖峰,尤其 batch size 只有 16 时。经验值是 batch size 每减半,温度调大到原来的 1.2 倍。
5.5 先跑单模态再开融合
新手最容易直接把config.yaml里use_contrastive设为true硬训,结果损失下降很慢。推荐的复现顺序是:先跑纯 EEG 单模态,确认数据管线和训练循环没问题;再开三模态拼接;最后才加 CLIP 对比损失。这样每一步的错误都有明确归属,results/results.txt里每一步的准确率变化也能作为排错依据。
本文还有配套的精品资源,点击获取