
简介基于Python开发的多模态融合情感分析项目以TensorFlow为底层框架融合文本含表情符号、语音、图片与视频四种模态输入将情感细分为喜、怒、哀、其他四类适合毕业设计、课程设计及进阶项目开发。资源包共21个文件整体大小58.48MB其中包含5个Python源码文件负责数据处理、模型构建与运行入口、9个pickle预处理数据集、IEMOCAP等数据集压缩包以及项目文档PDF、README说明、运行结果示意图片目录结构清楚便于按模块查阅。项目采用分层融合策略从单模态向量逐步扩展至双模态、三模态融合最后通过Softmax完成情感分类这种设计思路对于多模态学习入门很有参考价值。目前已有680人学习下载源码经过严格测试项目文档提供环境部署说明支持Python 3.6以上与TensorFlow 1.7并兼容CPU与GPU可直接参考并在此基础上扩展。1. 多模态融合情感分析先被测试数据打脸再把四路信号揉在一起凌晨两点我给答辩演示的 demo 跑完最后一批测试数据。视频里主角咬着嘴唇说“我没事你们先走”纯文本模型给出了接近 0.93 的正向情绪分可画面里眼眶是红的、声音是压着抖的。这就是我最终从文本情感分析转向多模态融合情感分析的原因现实里的情绪从来不是单通道产物文本、语音、图片、视频各携带一部分信息要先把它们抽成特征再按权重融合才不会因为某一通道的假象直接翻车。这个方向很适合毕业设计、课程设计和小团队做原型验证输入四路原始数据输出一个情感类别或维度分数代码量可控改进空间大跑完的源码、项目文档和数据集正好是一套完整的交付物。2. 数据、环境与项目骨架先让一条四路样本端到端跑通2.1 数据目录结构与命名规范多模态情感分析和单模态最不一样的地方是一开始就要面对四种格式的数据。文本是 txt语音是 wav图片是 jpg/png视频是 mp4/avi。如果文件夹结构和文件命名不统一后面写 DataLoader 时会反复改路径样本一多就乱。我现在的做法是每个样本一个独立目录四路文件用同一个 sample_id 前缀命名另加一个全局 CSV 记录标签和划分。mkdir -p data/samples/001 data/samples/002 touch data/samples/001/text.txt data/samples/001/audio.wav touch data/samples/001/image.jpg data/samples/001/video.mp4先随手建两个样本目录验证结构。真实数据集里一个样本的音频往往是从视频里抽出来的如果原始数据里只有 mp4 没有 wav用 ffmpeg 一次性导出即可ffmpeg -i data/samples/001/video.mp4 -ar 16000 -ac 1 data/samples/001/audio.wav这条命令把视频音轨转成 16kHz 单声道 wav。16kHz 是绝大多数语音预训练模型的标准输入采样率格式上先定死后面少踩坑。接着建标签文件 data/label.csv至少包含三列sample_id、label、splitsplit 用来做 train/val/test 划分不建议在代码里用随机数拆分标注文件里写死划分更利于复现。之后写一个最简陋的检查脚本一次性扫完所有样本缺文件的直接打印出来。这样在跑模型之前就把坏样本清掉而不是等训练到一半崩掉。扫描脚本本身不需要复杂逻辑遍历目录、检查四个后缀文件是否存在即可。2.2 Python 环境配置与依赖装完后的第一道自检这个项目涉及的库比较集中torch、torchaudio、transformers、opencv-python、librosa、pandas、numpy。不需要装完整的深度学习发行版requirements.txt 就够用。要注意的是 torch 相关包版本需要互相兼容尤其是 torchaudio 和 torch 必须严格配套否则 import 时直接报错报错信息看着像“某函数不存在”实际是版本对不上。torch1.13.0 torchaudio0.13.0 transformers4.30.0 numpy1.21.0 pandas1.5.0 opencv-python4.6.0 librosa0.9.0 soundfile0.11.0 Pillow9.0.0装完依赖之后先做一道自检不要急着写业务代码。在命令行里执行下面这一段确认 torch 能看到 GPUAudio 后端能正常读取 wavpython -c import torch; print(torch.__version__, torch.cuda.is_available())CUDA 版本和 torch 版本不匹配是最常见的环境问题。如果你用的是 NVIDIA 显卡先nvidia-smi看驱动支持的最高 CUDA 版本再选对应的 torch 安装命令。没有 GPU 的机器也能跑但训练速度和视频抽帧速度会慢到让人失去耐心建议至少有一块 6GB 以上显存的卡。在 VSCode 里要做两件小事解释器选到刚建好的虚拟环境终端确认which python指向的是这个环境不然就会出现“命令行能 importVSCode 里 import 失败”的经典乌龙。2.3 源码与项目文档的目录组织毕设或课程设计交付时需要“源码项目文档数据集”三件套。源码部分不要只堆一个 main.py。我的组织习惯是src 放核心代码config 放配置notebooks 放探索分析docs 放项目文档。这样评审老师看目录就知道工程结构完整自己后面回来改代码也能快速定位。project/ ├── config/config.yaml ├── data/ ├── src/ │ ├── data_loader.py │ ├── extract_features.py │ ├── fusion_model.py │ └── train.py ├── docs/项目文档.md ├── requirements.txt └── README.mdREADME 至少写五块数据集来源与目录结构、环境安装步骤、每个脚本的入口和参数、训练日志或实验记录表、复现结果与指标。其中实验记录表要养成随手填的习惯日期、模型结构、融合方式、准确率、F1 列清楚后面写文档、做对比实验都会用到。很多项目最后翻车在文档缺失上代码能跑但说不清设计过程答辩时很吃亏。数据集部分如果用的是公开数据在文档里标明来源与划分方式即可如果是自制数据要补上采集方式、标注规则、各类别样本数量。标注规则尤其要提前定清楚一段视频里的情绪到底以谁为准说话人还是画面主体这会直接影响标注一致性。3. 特征提取BERT、Wav2Vec2 与 ResNet50 如何对齐到同一个向量空间3.1 文本分支CLS 向量与截断策略别把“不”字切掉文本特征提取最省事、效果也最稳的方式是用预训练语言模型把整句话编码成一个向量而不是自己训练词向量。中文场景我一般用 bert-base-chinese直接取模型输出的 CLS 向量作为整段文本的语义表示。模型权重可以从 HuggingFace 下载也可以直接加载本地目录路径。重点是截断策略max_length 设成 128 时长文本会把尾部切掉而情感词的分布规律是两头多中间少“但是后来我一点也不喜欢了”这类转折句被切断后语义可能完全反了。from transformers import BertTokenizer, BertModel import torch tokenizer BertTokenizer.from_pretrained(bert-base-chinese) model BertModel.from_pretrained(bert-base-chinese) model.eval() def get_text_embedding(text: str, max_length: int 128): # encode 后截断truncation 控制在 tokenizer 里 inputs tokenizer( text, max_lengthmax_length, truncationTrue, paddingmax_length, return_tensorspt, ) with torch.no_grad(): outputs model(**inputs) # 取 CLS 向量[batch_size, hidden_size] return outputs.last_hidden_state[:, 0, :].squeeze(0)CLS 向量本质上是模型内部对这个序列做的“整体总结”下游做分类或拼接其他模态特征时都用它。hidden_size 是 768 维后面融合层要按这个维度设计输入。max_length 参数在预训练模型里默认是 512但大部分短文本情感分析用 128 就够太长反而稀释信息。对“我喜欢这个产品”和“我不喜欢这个产品”这两条文本BERT 编码出的向量差异会在第 3 位左右开始显式显现这也是后面判断模态间是否冲突的一个线索。3.2 语音分支16kHz 采样率与定长重采样语音特征提取同样优先用预训练模型Wav2Vec2 是当前效果比较稳的选择。它会把原始波形直接编码成帧级特征序列再对序列做均值池化得到一个整段语音的向量。这里 16kHz 采样率是硬规定如果训练语料是 44.1kHz 录音直接喂给 Wav2Vec2 特征会偏移必须先重采样。librosa 和 torchaudio 都能做但 torchaudio 更顺因为它和后续 tensor 计算是同一套生态。import torchaudio from transformers import Wav2Vec2Processor, Wav2Vec2Model processor Wav2Vec2Processor.from_pretrained(facebook/wav2vec2-base) model Wav2Vec2Model.from_pretrained(facebook/wav2vec2-base) def load_audio(path: str, target_sr: int 16000, max_seconds: float 8.0): waveform, sr torchaudio.load(path) # 统一采样率sr 不等于 target_sr 时重采样 if sr ! target_sr: waveform torchaudio.functional.resample(waveform, sr, target_sr) # 定长截断或 padding保证 batch 内维度一致 max_len int(target_sr * max_seconds) if waveform.size(1) max_len: waveform waveform[:, :max_len] elif waveform.size(1) max_len: pad torch.zeros((1, max_len - waveform.size(1))) waveform torch.cat([waveform, pad], dim1) return waveform def get_audio_embedding(path: str): waveform load_audio(path).squeeze(0) inputs processor(waveform, sampling_rate16000, return_tensorspt) with torch.no_grad(): outputs model(**inputs).last_hidden_state # 时间维度取均值得到整段语音的句子级向量 return outputs.mean(dim1).squeeze(0)注意这里的 load_audio 是全局控制截断还是 padding 取决于 max_seconds 值。短视频情绪分析里 8 秒足够覆盖一句话如果样本是几十秒的长语音直接截断会丢语义实际操作里可以先把 VAD 检测到的非静音片段切出来再逐段提取但课程设计阶段不需要做到这步。Wav2Vec2-base 的 hidden_size 也是 768和文本分支一致这给后面的融合层省了些功夫。3.3 图片与视频分支抽帧、池化与帧数选择图片和视频的特征提取思路不太一样。单张图片直接用卷积网络提特征视频则需要先抽帧再对帧特征做聚合。为什么不用 OpenCV 把视频所有帧都提一遍一是计算量成倍涨二是相邻帧高度冗余抽到的特征大多重复对情感判断增益很小。常见做法是每段视频均匀抽 8 帧用 ResNet50 去掉最后的全连接层得到每帧的 2048 维特征最后对 8 帧取均值池化作为视频分支的整体表示。import cv2 import torch import torchvision.transforms as T from torchvision.models import resnet50 model resnet50(pretrainedTrue) # 去掉最后一层全连接只保留特征提取部分 feature_extractor torch.nn.Sequential(*list(model.children())[:-1]) feature_extractor.eval() transform T.Compose([ T.ToPILImage(), T.Resize((224, 224)), T.ToTensor(), T.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]), ]) def extract_video_embedding(video_path: str, num_frames: int 8): cap cv2.VideoCapture(video_path) total int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) # 均匀取 num_frames 个帧索引避免前后景信息丢失 indices [int(i * total / num_frames) for i in range(num_frames)] frames [] for i in range(total): ret, frame cap.read() if i in indices: frames.append(transform(frame)) cap.release() frames_tensor torch.stack(frames) # [num_frames, 3, 224, 224] with torch.no_grad(): feat feature_extractor(frames_tensor) # [num_frames, 2048, 1, 1] feat feat.view(num_frames, -1) return feat.mean(dim0) # 8 帧取均值 - [2048]这里有个细节ResNet50 平均池化之后得到的是 2048 维向量而文本和语音都是 768 维。维度不一样时不要急着 concat先统一映射到同一维度再做融合。num_frames 取 8 是我较常用的默认值短视频够用如果样本本身超过 30 秒建议把 8 提到 16否则覆盖不到情绪变化的关键片段。图片分支就简单得多单张图走同样的 transform 和特征提取器得到的就是图片的情感相关视觉特征。三种特征都拿到之后面临一个绕不开的问题文本是 word-level 离散序列语音和视频是帧级连续序列它们在时间轴上天然不对齐。课程设计和毕设阶段不需要追求帧级的细粒度对齐统一对每个模态做全局池化再在特征层拼接已经能得到相当稳定的结果。这也是后面融合层输入的三个向量都保持“一个样本一个向量”的原因。4. 融合层与情感预测early fusion、late fusion 和注意力加权怎么选4.1 三种融合结构各自的适用边界多模态融合在项目里最常见的落地方案有三类。early fusion 把三个特征在输入阶段直接拼接后面接分类器简单直接但拼接后维度膨胀且三个模态共享一个网络无法显式控制某一模态的噪声影响。late fusion 则是每个模态各训一个分类器最后投票或取概率平均互不干扰但每个模态单独训练时丢失了模态间的互补信息。第三种是注意力融合这是目前更推荐的做法三个模态先各自映射到同一维度再用学习出来的权重决定每个样本更信任哪个模态。融合方式优点缺点适合场景early fusion实现最简单训练端到端特征维度高模态噪声互相干扰模态数量少、特征维度接近late fusion各模态独立容错强丢失模态间相关性已有多个单模态模型注意力融合可学习权重能观察模态贡献结构稍复杂需调注意力参数样本内容差异大要识别模态优先级情感分析里的真实情况往往是视频里有人的表情音频里有语气文本里有字面意思。对“我没事”这种文本字面是中性偏正向但音频特征是低沉的视频特征是沮丧的这种冲突只有注意力融合能显式学出一个“此时应该更相信音频和视频”的权重early 和 late 都做不到。4.2 一个轻量注意力融合层的 PyTorch 实现这里给一个可以直接抄的注意力融合模块。输入是一个字典存放各模态的特征向量输出是融合后的向量和每个模态的权重。投影层的 hidden_size 设 256把 768 维的文本、768 维的语音、2048 维的视频统一映射到 256 维再做 softmax 加权求和。这样设计的核心是如果某个模态在这个样本上是噪音它的注意力权重会自动变小。import torch import torch.nn as nn class AttentionFusion(nn.Module): def __init__(self, feat_dims: dict, hidden_size: int 256): super().__init__() # feat_dims 示例: {text: 768, audio: 768, video: 2048} self.projectors nn.ModuleDict({ name: nn.Linear(dim, hidden_size) for name, dim in feat_dims.items() }) self.attn nn.Linear(hidden_size, 1) def forward(self, feats: dict): # feats 示例: {text: [batch, 768], audio: [batch, 768], video: [batch, 2048]} projected { name: torch.tanh(self.projectors[name](feats[name])) for name in feats } stacked torch.stack(list(projected.values()), dim1) # [batch, num_mod, hidden] score self.attn(stacked).squeeze(-1) # [batch, num_mod] weight torch.softmax(score, dim-1) fused (stacked * weight.unsqueeze(-1)).sum(dim1) # [batch, hidden] return fused, weight这段代码的关键在于 torch.stack 之前三个模态必须在 batch 维上对齐。DataLoader 返回的 dict 里每个张量的第一维都应是 batch_size。tanh 在这里的作用是给投影层输出加一个非线性变换让注意力分数计算得更充分。softmax 保证权重和为 1相当于在做加权平均而不是直接求和融合后的向量仍是 256 维后面接一个全连接层做三分类即可。训练时若发现某一模态的权重始终接近 0说明该模态特征质量差或与标签相关性低这本身就是一个诊断信号数据标注或者特征提取环节需要回查。4.3 标签体系、损失函数与不平衡样本处理情感标签通常有两种设计。一种是离散类别negative / neutral / positive 三分类简单直接也是课程设计里最常用的。另一种是连续维度valence-arousal 二维回归能描述更细腻的情感但标注难度大、评估指标也麻烦毕设如果有余力可以尝试否则三分类更稳妥。损失函数上多分类任务用交叉熵特别要注意样本不平衡现实数据里 neutral 往往占一半以上如果直接把原始数据喂进去模型会学会永远输出 neutral因为这样准确率最高。处理不平衡有两个顺手又能写进文档的办法。一是对损失做类别加权把数量少的类别权重调大二是用 Focal Loss 替代标准交叉熵让模型更关注难分类样本。实践上如果 negative 样本是 positive 的一半weight 设置成接近样本量反比即可不需要精确计算。代码层面在模型输出层加一个 Linear(256, 3) 分类头训练时计算加权交叉熵验证时同时看准确率和 F1不要单看准确率因为不平衡场景下准确率没有参考意义。5. 多模态训练常见问题与排查5 条能直接抄的踩坑记录5.1 视频抽帧全部读进内存显存直接溢出现象训练到第二个 epoch 时CUDa out of memory 报错显存占用曲线稳步上升。原因视频抽帧脚本一次性把所有帧都读取并 transform 成张量全堆在 GPU 上做特征提取。我当时抽了一个 60 秒视频直接产出了 1500 帧每帧 3×224×224 的 float 张量显存瞬间被打满。解决抽帧数量严格限制video 分支统一只保留均匀抽取的 8 帧或 16 帧特征提取全过程用 torch.no_grad()并且提取完立刻把帧张量释放如果视频很长分片提取特征再拼接不要让所有帧同时驻留在显存里。另一个附加方案是先用 CPU 做特征提取把特征向量保存成 npy 文件训练时只读取特征不重算。这套做法叫“特征缓存”在多模态项目里非常实用。5.2 音频采样率不统一同一句话两种预测结果现象同一个样本白天跑出 negative晚上重跑变成 neutral检查代码逻辑没变数据也没动。原因数据集里一部分 wav 是 44.1kHz 的录音室素材另一部分是 16kHz 的采集素材。Wav2Vec2 对采样率极度敏感44.1kHz 的波形在预处理时被当成了 16kHz 去理解频谱结构整体偏移特征自然不同。解决在 load_audio 里强制重采样到 16kHz并且把 target_sr 参数写死不依赖外部配置。写完后再抽查一批样本打印实际采样率确认数据集里没有漏网之鱼。这个坑属于“数据清理阶段没做干净”不是模型问题但排查起来最费时间。5.3 文本截断把转折句从中间切断情感标签全错现象验证集上其他模态预测正确只要文本是长句模型几乎都判错。看错误样本“其实我很喜欢但是后来实在受不了”被判成 negative但真实标签是 positive。原因max_length 是 64长句后半段的“受不了”保留下来了“其实我很喜欢”被切掉模型只看到了负面语义。解决把 max_length 从 64 调到 128。如果文本超长优先保留首尾拼接而不是硬截断尾部。情感表达不均匀分布在句子头尾截断策略要保护首尾不能简单地从头截。5.4 融合后效果反而不如单模态文本现象加了视频和音频特征之后F1 比只跑 BERT 文本还低了 3 个百分点。这在答辩场景里非常尴尬。原因特征拼接是一个“全信任”操作无论某个模态质量多差它都会贡献噪声。文本很清楚视频光线暗、面部遮挡严重特征质量差拼接后把文本的判别信息稀释了。解决换注意力融合让模型自己学每个模态的权重同时检查是否存在大量“模态缺失”样本比如某个样本没有视频文件代码里用零向量填充却把零向量当成真实特征参与注意力计算。对缺失模态要在 mask 上做处理让注意力权重对缺失通道强制归零。5.5 数据集标签不平衡全模型输出 neutral现象训练损失下降正常但训练集准确率一直停留在 60% 不再动看预测分布所有样本全是 neutral。原因数据集里 neutral 占比 60%负样本只有 15%。模型发现全预测 neutral 也能拿到 0.6 的准确率梯度下降不会再主动调整。解决用加权交叉熵把 negative 类别的权重设到 2~3 倍评估时用 macro F1而不是 accuracy如果正负样本悬殊超过 5 倍可以考虑对负样本做简单的数据增强比如语音加噪、视频做亮度抖动而不是单纯改损失函数。这里要注意我试过把正样本重复复制进训练集模型出现过拟合加了轻微噪声的增强比简单复制更可靠。6. 验证与进阶用冲突样本判断融合是否真的比单模态强6.1 多模态模型的验证不能只看准确率很多课程设计止步于“准确率 0.82”但答辩老师最常问的问题是融合到底带来了什么回答这个问题的最好方式是把单模态模型和融合模型在验证集上的预测结果做成对比表尤其关注“文本预测 positive融合预测 negative人工标注 negative”这一类的冲突样本。找一个视频台词是“我没事”画面是哭脸文本模型给 positive融合模型给 negative这就是融合价值的直观证明。我一般会在每个 epoch 结束时跑一遍“冲突样本统计”把三个单模态分支各自预测的一致率和融合预测的翻转情况打印出来。如果发现融合结果和文本分支永远一致说明注意力权重学偏了这时去检查权重分布大概率是 audio 或 video 分支的特征一开始就没有收敛。6.2 三个可上手的进阶验证手段第一存权重可视化。融合层训练完后随机抽 100 个样本统计每个模态的平均注意力权重。如果 num_frames 从 8 改成 16 后权重明显变化说明视频分支特征更丰富了这是一个能写进项目文档的观察结论。第二跨数据集测试。用一个不同的数据集做测试哪怕标签体系不一致也可以把标签粗映射成三分类再比较观察融合模型的泛化能力。第三错误样本人工复核。融合模型预测错的样本里到底是人的标注有歧义还是视频画面确实没提供有效信息把出错原因分类写进文档。这三步下来模型体系就立住了。6.3 注意力机制之外的一个稳赚技巧如果想把效果再往上提一点可以试试“模态丢失训练法”训练时随机丢弃某一个模态的输入特征让模型在缺失一个模态的情况下仍然能学习。这个技巧让模型不会过度依赖单一模态尤其在测试集存在部分模态缺失时鲁棒性提升非常明显。丢弃概率设 0.2 即可不用太高。实现上就是在训练循环里对 feats 的某个键做随机置零代码改动不超过五行的思路。我现在做多模态项目都默认带上这个操作已经成为习惯。希望这些坑位和参数能帮你少走几段弯路如果按这套结构跑通了一条样本再回头看整个项目的文档和源码你会很清楚怎么把它们讲成一个有数据、有结论、有取舍的故事希望帮到你。本文还有配套的精品资源点击获取