简介:这是一套基于Python实现的多模态融合情感分析完整项目,面向需要完成毕业设计、期末大作业或课程设计的学生,以及希望入门多模态学习的开发者。项目支持文本、语音、图片、视频四类输入,覆盖MOSI、MOSEI、IEMOCAP等常用数据集,可帮助读者理解多模态特征提取与融合建模的完整流程。资源包共20个文件,包含5个py源码脚本、9个pickle预处理特征文件、3个zip数据集压缩包,以及1份pdf项目文档、1份md说明和1张结果图,整体约56.91MB,代码注释清晰、结构完整。目前已有96人学习。读者可获得可直接运行的源码、数据预处理脚本、模型定义文件与配套文档,便于快速复现实验、撰写论文或在此基础上二次开发,适合新手对照学习与调试。
1. 多模态情感分析系统:一份能跑通文本、语音、图像、视频四路输入的毕设级源码包
如果你正在为毕业设计或期末大作业找一个「技术栈够宽、能演示、有数据集、代码能跑」的题目,多模态情感分析几乎是命中率最高的方向之一。它同时踩中了情感计算、多模态融合、深度学习部署三个热点,答辩时老师问得出问题,演示时又能直观看到「同一句话配不同表情,模型给出不同情绪判断」的效果。但真正动手时,大多数人会卡在三个地方:四路模态的数据怎么对齐、融合层到底怎么拼、数据集从哪来。这份资源把文本、语音、图像、视频四条链路的源码、文档和数据集打包在一起,省掉了最耗时的数据采集和预处理环节。它适合两类人:一是时间紧、需要快速搭出可演示系统的同学;二是想理解多模态融合机制、但不想从零写数据管线的开发者。下面按「先跑通、再拆解、后避坑」的顺序,把这份包拆开讲清楚。
2. 四模态数据管线:从原始文件到模型输入的完整链路
拿到一个多模态项目,最忌讳一上来就翻模型代码。模型结构再花哨,数据管线对不上,训练时 loss 不动或者直接报 shape 错误,你连问题出在哪都找不到。这一章先把四条模态各自的数据处理链路讲透,每条链路都落到可执行的代码和参数上。
2.1 文本模态:分词、截断与嵌入层对齐
文本链路的核心是把变长句子变成固定长度的向量序列。这份源码里文本部分用的是预训练词向量加 BiLSTM 的经典组合,预处理脚本在data_process/text_pipeline.py。关键参数有三个:最大序列长度、词表大小、填充策略。
# text_pipeline.py 核心片段 import torch from torch.nn.utils.rnn import pad_sequence from transformers import BertTokenizer # 常见做法是用预训练 tokenizer,词表大小 30522 对应 bert-base 中文词表 tokenizer = BertTokenizer.from_pretrained('bert-base-chinese') MAX_LEN = 128 # 超过截断,不足补 PAD def encode_text(sentences, max_len=MAX_LEN): encodings = tokenizer( sentences, padding='max_length', # 统一补齐到 max_len truncation=True, # 超长直接截断 max_length=max_len, return_tensors='pt' ) return encodings['input_ids'], encodings['attention_mask']逻辑说明:padding='max_length'保证一个 batch 内所有样本长度一致,避免后续pad_sequence再手动处理。attention_mask必须保留,因为补出来的 PAD 位置在注意力计算里要屏蔽掉,否则模型会把填充符当成真实语义。参数上,MAX_LEN=128是中文情感分析任务的常见取值,短文本数据集(如在线评论)够用;如果你的文本是长影评,建议调到 256,但显存占用会明显上升。截断策略默认从尾部截,如果关键情感词常出现在句尾,要改成truncation='only_first'并手动保留尾部。
2.2 语音与图像:梅尔频谱和面部区域裁剪
语音链路走的是「音频转梅尔频谱图,再当图像处理」的路线,这样语音和图像可以共用同一个 CNN 骨干,减少参数量。源码里语音预处理在data_process/audio_pipeline.py,图像在image_pipeline.py。
# audio_pipeline.py 核心片段 import librosa import numpy as np SAMPLE_RATE = 16000 # 统一采样率,常见语音模型标准 N_MELS = 128 # 梅尔滤波器组数量 HOP_LENGTH = 512 # 帧移,决定时间维度分辨率 def audio_to_mel(wav_path): y, sr = librosa.load(wav_path, sr=SAMPLE_RATE) mel = librosa.feature.melspectrogram( y=y, sr=sr, n_mels=N_MELS, hop_length=HOP_LENGTH ) log_mel = librosa.power_to_db(mel, ref=np.max) # 转 dB,压缩动态范围 return log_mel # 形状 (128, T)逻辑说明:SAMPLE_RATE=16000是语音情感识别的主流采样率,高于这个值对情感判别帮助有限,反而增加计算量。N_MELS=128比常见的 64 保留了更多频带细节,适合情感这种需要捕捉韵律变化的任务。power_to_db这一步不能省,原始功率谱数值跨度大,转 dB 后模型更容易收敛。图像链路则用 MTCNN 或 OpenCV 的 Haar 级联做面部检测,裁出人脸区域再缩放到 224×224,和语音频谱图一起送进 CNN。这里有个容易忽略的点:语音频谱图的时间维度 T 是变化的,需要统一 resize 到固定尺寸,源码里用的是双线性插值,参数在配置文件config.yaml的audio.resize项。
2.3 视频模态:抽帧、对齐与时间维度压缩
视频是最麻烦的一路,因为它同时包含视觉和听觉信息,而且时间维度最长。源码的处理策略是:按固定帧率抽帧,每段视频取 16 帧,同时提取对应的音频段,视觉和听觉分别编码后再做时序融合。
# video_pipeline.py 核心片段 import cv2 FRAME_NUM = 16 # 每段视频抽帧数 FRAME_SIZE = 224 # 单帧缩放尺寸 def extract_frames(video_path, frame_num=FRAME_NUM): cap = cv2.VideoCapture(video_path) total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) # 均匀采样,避免只取到开头或结尾 indices = np.linspace(0, total - 1, frame_num, dtype=int) frames = [] for idx in indices: cap.set(cv2.CAP_PROP_POS_FRAMES, idx) ret, frame = cap.read() if ret: frame = cv2.resize(frame, (FRAME_SIZE, FRAME_SIZE)) frames.append(frame) cap.release() return np.array(frames) # (16, 224, 224, 3)逻辑说明:np.linspace做均匀采样是关键,如果直接取前 16 帧,遇到长视频就只覆盖了开头几秒,情感信息严重丢失。FRAME_NUM=16是精度和显存的折中,调到 32 能捕捉更细的表情变化,但 batch size 要相应减半。抽出来的帧序列送进 3D CNN 或 TimeSformer 做时序编码,源码默认用的是轻量化的 3D ResNet,参数量小,适合单卡训练。视频对应的音频段用同样的梅尔频谱流程处理,最后视觉和听觉特征在通道维度拼接。
提示:四条链路的数据必须按样本 ID 严格对齐,源码里用
sample_id作为主键,文本、语音、图像、视频文件命名都带这个 ID。如果你自己换数据集,命名规则一定要统一,否则训练时会出现「文本配错语音」的玄学问题。
3. 融合层设计:早期融合、晚期融合还是交叉注意力
数据管线跑通后,下一个决策点是融合策略。这不是拍脑袋选的,不同融合方式对最终指标的影响可能超过模型深度本身。这一章把源码里实现的三种融合方式拆开对比,并给出选择依据。
3.1 三种融合方式的代码实现与参数量对比
源码在models/fusion.py里实现了三种融合:早期融合(拼接)、晚期融合(投票)、交叉注意力(跨模态交互)。早期融合最简单,把四路特征在通道维度拼起来送进分类器;晚期融合是每路单独出预测结果,再加权平均;交叉注意力让文本特征去 query 语音和图像特征,捕捉模态间的关联。
# fusion.py 核心片段:交叉注意力融合 import torch.nn as nn class CrossAttentionFusion(nn.Module): def __init__(self, dim=256, num_heads=4): super().__init__() self.text_proj = nn.Linear(768, dim) # BERT 输出 768 维 self.audio_proj = nn.Linear(128, dim) # 梅尔特征 128 维 self.image_proj = nn.Linear(512, dim) # CNN 输出 512 维 self.cross_attn = nn.MultiheadAttention(dim, num_heads, batch_first=True) self.classifier = nn.Linear(dim, 3) # 三分类:积极/消极/中性 def forward(self, text_feat, audio_feat, image_feat): t = self.text_proj(text_feat).unsqueeze(1) a = self.audio_proj(audio_feat).unsqueeze(1) i = self.image_proj(image_feat).unsqueeze(1) # 文本作为 query,语音和图像作为 key/value fused, _ = self.cross_attn(t, torch.cat([a, i], dim=1), torch.cat([a, i], dim=1)) return self.classifier(fused.squeeze(1))逻辑说明:num_heads=4是注意力头数的常用起点,头数太多在小数据集上容易过拟合。三个投影层把不同模态的特征统一到 256 维,这是为了后续注意力计算时维度匹配。文本作为 query 的设计思路是:情感判定的主导信息通常来自文本,语音和图像起补充和修正作用。如果你的场景里语音韵律更重要(比如呼叫中心情绪识别),可以把语音设为 query。
| 融合方式 | 参数量 | 训练速度 | 适合场景 |
|---|---|---|---|
| 早期融合 | 最少 | 最快 | 模态对齐好、数据量小 |
| 晚期融合 | 中等 | 中等 | 某路模态缺失严重 |
| 交叉注意力 | 最多 | 最慢 | 模态间关联强、数据充足 |
3.2 模态缺失时的降级策略
实际部署时经常遇到某路模态缺失的情况,比如只有文本没有视频。源码里做了一个降级机制:当某路特征全为零时,融合层自动跳过对应的投影和注意力计算,用剩余模态出结果。这个逻辑在fusion.py的forward里通过 mask 实现。
# 模态缺失处理片段 def forward(self, text_feat, audio_feat=None, image_feat=None): t = self.text_proj(text_feat).unsqueeze(1) available = [t] if audio_feat is not None: available.append(self.audio_proj(audio_feat).unsqueeze(1)) if image_feat is not None: available.append(self.image_proj(image_feat).unsqueeze(1)) kv = torch.cat(available[1:], dim=1) if len(available) > 1 else t fused, _ = self.cross_attn(t, kv, kv) return self.classifier(fused.squeeze(1))逻辑说明:available列表动态收集可用的模态特征,kv在只有文本时退化为文本自身,相当于自注意力。这个设计让同一套模型权重能适应不同模态组合的输入,不用为每种组合单独训练。参数上不需要额外调整,但要注意:如果训练时四路齐全、推理时缺了两路,指标会下降,建议在训练阶段就随机 drop 掉某路模态做数据增强,提升鲁棒性。
注意:交叉注意力融合的显存占用和模态数量是平方关系,四路模态全开时,单卡 8G 显存跑 batch size 16 会比较紧张。如果显存不够,优先降 batch size,不要轻易减注意力头数,头数减少对指标的影响更明显。
4. 训练与评估:损失函数、指标和常见翻车点
模型搭好之后,训练过程本身也有不少讲究。多模态任务的损失函数设计、评估指标选择、以及训练中的不稳定现象,都是实际动手时绕不开的。这一章把源码里的训练配置和评估逻辑讲清楚,并给出几个我踩过的坑。
4.1 损失函数与类别不平衡处理
情感分析数据集天然存在类别不平衡,积极样本往往远多于消极样本。源码默认用带类别权重的交叉熵损失,权重根据训练集类别频率自动计算。
# train.py 损失函数片段 import torch.nn as nn # 假设训练集类别分布:积极 6000,消极 2000,中性 2000 class_counts = [6000, 2000, 2000] weights = torch.tensor([1.0 / c for c in class_counts]) weights = weights / weights.sum() * len(class_counts) # 归一化 criterion = nn.CrossEntropyLoss(weight=weights)逻辑说明:weight参数让少数类样本的损失被放大,避免模型偏向多数类。归一化那一步是为了让权重均值保持在 1 附近,不改变整体学习率量级。如果你的数据集里中性样本特别少,可以把中性类的权重再手动乘 1.5。另一个常见做法是过采样少数类,但多模态数据过采样容易导致同一视频被重复抽帧,反而过拟合,源码里没采用。
4.2 评估指标:准确率之外的 F1 和混淆矩阵
多模态情感分析不能只看准确率。如果消极样本只占 10%,模型全预测积极也能有 90% 准确率,但完全没用。源码的评估脚本eval.py同时输出宏平均 F1、加权 F1 和混淆矩阵。
# eval.py 指标计算片段 from sklearn.metrics import f1_score, confusion_matrix def evaluate(y_true, y_pred): macro_f1 = f1_score(y_true, y_pred, average='macro') weighted_f1 = f1_score(y_true, y_pred, average='weighted') cm = confusion_matrix(y_true, y_pred) return macro_f1, weighted_f1, cm逻辑说明:macro平均对每个类别一视同仁,适合关注少数类表现的场景;weighted按类别样本数加权,更接近整体准确率的直觉。混淆矩阵能看出模型到底把哪两类搞混了,比如积极和中性混淆多,说明模型对弱情感不敏感,可以考虑增加中性类的训练样本或调整分类阈值。参数上没有需要调的,但建议每次评估都保存混淆矩阵图,答辩时直接展示,比单纯报数字有说服力。
提示:训练时如果发现 loss 震荡厉害,先检查学习率是不是太大。多模态融合层的梯度通常比单模态大,源码默认学习率 1e-4,比纯文本任务的 2e-5 高,但比从头训练的 1e-3 低。如果用了预训练 BERT,文本分支的学习率要单独调小,源码里用参数组实现了差分学习率。
5. 避坑与排查:四模态项目里最容易翻车的五个地方
这一章不讲新功能,专门记录我在跑这份源码和类似项目时遇到的真实问题。每条按「现象 → 原因 → 解决」写,你遇到类似情况可以直接对照。
现象一:训练时 loss 一直是 nan。原因:语音梅尔频谱转 dB 时出现了 log(0),power_to_db内部有截断,但如果音频文件本身是静音或损坏,功率谱全零,转 dB 后是负无穷。解决:在audio_to_mel里加一个极小值保护,mel = np.maximum(mel, 1e-10),再转 dB。另外检查数据集里有没有时长为零的音频文件。
现象二:视频模态的准确率远低于文本模态。原因:抽帧时用了np.linspace均匀采样,但视频里人脸可能只出现在某几秒,均匀采样恰好避开了人脸区域。解决:先用人脸检测筛出含人脸的帧,再在这些帧里均匀采样。源码里提供了一个可选的人脸优先采样开关,在config.yaml的video.face_first设为true。
现象三:四路模态全开时显存溢出。原因:交叉注意力融合的中间张量没及时释放,加上 BERT 的 768 维输出和 CNN 的 512 维输出拼接后维度很大。解决:在融合层前加一个torch.cuda.empty_cache()不解决根本问题,正确做法是把文本分支的 BERT 换成蒸馏版(如 6 层),或者把 batch size 降到 8,同时开启梯度累积模拟大 batch。
现象四:验证集指标比训练集还高。原因:数据泄露。四条模态的数据划分没有按样本 ID 统一,可能出现同一个样本的文本在训练集、语音在验证集的情况。解决:划分数据集时以sample_id为单位整体划分,源码的split_dataset.py已经做了这件事,但如果你自己换数据,务必检查。
现象五:推理时单条样本预测结果和批量预测不一致。原因:批量推理时 padding 和 attention mask 处理正确,单条推理时忘了加 batch 维度,导致模型把序列长度当成了 batch size。解决:单条推理也要unsqueeze(0)加 batch 维,并且 attention mask 要同步加。这个坑很隐蔽,因为模型不报错,只是结果乱掉。
6. 从跑通到用起来:模型导出、推理加速和一个小技巧
把训练跑通只是第一步,真正要拿去演示或集成到应用里,还得解决导出和推理速度的问题。这一章讲两个实用技巧:怎么把 PyTorch 模型导出成 ONNX 提升推理速度,以及怎么用置信度阈值过滤低质量预测。
6.1 导出 ONNX 并验证数值一致性
PyTorch 模型在 Python 环境里推理没问题,但如果要集成到其他语言的服务里,ONNX 是更通用的格式。导出时最大的坑是动态维度处理,多模态输入的序列长度和模态组合都是可变的。
# export_onnx.py 核心片段 import torch model.eval() dummy_text = torch.randn(1, 128, 768) dummy_audio = torch.randn(1, 128) dummy_image = torch.randn(1, 512) torch.onnx.export( model, (dummy_text, dummy_audio, dummy_image), 'multimodal_sentiment.onnx', input_names=['text', 'audio', 'image'], output_names=['logits'], dynamic_axes={ 'text': {0: 'batch', 1: 'seq_len'}, 'audio': {0: 'batch'}, 'image': {0: 'batch'} }, opset_version=14 )逻辑说明:dynamic_axes把 batch 维和文本序列长度标为动态,这样导出后的模型能接受不同 batch size 和不同长度的文本。opset_version=14对 MultiheadAttention 的支持比较完整,低于 12 的版本可能报不支持的操作。导出后一定要用onnxruntime跑一遍同样的输入,对比 PyTorch 和 ONNX 的输出差异,如果最大绝对误差超过 1e-4,说明某些算子导出有问题,常见的是 LayerNorm 和 GELU。
6.2 置信度阈值过滤与人工复核队列
情感分析模型在遇到模棱两可的样本时,softmax 输出的最大概率可能只有 0.4 左右,这种预测直接采用风险很大。我的习惯是在推理脚本里加一个置信度阈值,低于阈值的样本不直接输出结果,而是打上「待复核」标签。
# inference.py 置信度过滤片段 import torch.nn.functional as F def predict_with_threshold(model, inputs, threshold=0.6): logits = model(*inputs) probs = F.softmax(logits, dim=-1) max_prob, pred = probs.max(dim=-1) if max_prob.item() < threshold: return '待复核', max_prob.item() return pred.item(), max_prob.item()逻辑说明:threshold=0.6是经验值,低于这个值说明模型对判断没把握。这个机制在演示时特别有用,你可以展示「模型知道自己不确定」的能力,比强行输出一个错误结果更可信。阈值调高会减少误判但增加人工工作量,调低则相反,根据你的场景容忍度来定。我一般会在验证集上画一条阈值-准确率曲线,选准确率开始明显下降的拐点作为阈值。
从那以后我每次交付多模态项目,都会强制走一遍「单条推理一致性检查」和「置信度分布可视化」,这两个步骤帮我拦住了至少三次会在演示现场翻车的问题。希望帮到你。
本文还有配套的精品资源,点击获取