简介:面向文本、语音、图像与视频四类输入的多模态情感分析系统,以完整可运行的Python工程交付,定位服务于毕业设计、期末大作业与课程设计等学术场景。系统具备数据处理、特征提取、模型训练、情感预测与可视化模块,代码注释清晰,界面简洁,运行稳定,尤其适合希望借助完整案例掌握多模态融合与情感识别流程的初学者。压缩包共二十四个文件,体积约67.58MB,以Python脚本实现核心逻辑,pickle文件保存中间特征与预处理结果,zip压缩包存放文本、音视频标注数据集,PDF与Markdown文档提供开发说明,PNG图片展示运行效果,zbak备份便于回溯调整。项目包括数据准备脚本、模型定义、主运行脚本及已标注数据集,可支撑从原始数据到情感预测的完整复现;已有四十八人浏览学习,可直接作为学术项目模板,结合文档与脚本快速搭建实验环境,并对不同模态、不同融合方式开展对比验证。
1. 多模态情感分析系统是什么:先想清楚融合什么,再动手写代码
一个客服质检员听到用户语气急促、文本却很客气时,会本能地打上“不满”的标签;只给大模型看聊天记录,它大概率会判成“中性”。这就是多模态情感分析系统存在的理由:把文本、语音、图像、视频四类输入抽成特征,再通过融合策略综合判断情绪,让结果更接近人类听话听音、观其行的方式。标题提到的这份Python项目,我按从业者的习惯做法拆成一套可复现的工程链路:数据集怎么组织、四个模态怎么预处理、融合模型怎么搭、训练参数怎么设、最后怎么证明每个模态真有贡献。
这套系统能直接解决的问题很具体:客服质检里给对话自动打情绪标签,课堂场景识别学生的投入度,舆情应用里判断短视频的总体情感倾向。适合已经会跑通单一模态分类、想往多模态走的Python工程师——照着第2章到第5章的顺序实现一遍,你得到的不是一个跑分的玩具,而是一个能端到端产出预测的文件级系统。
2. 四路输入的预处理:把文本、语音、图像、视频统一成模型能吃的张量
2.1 数据集目录规划:先定“样本边界”,再谈模型
很多同学是从“处理数据集用于yolov8训练”那类流程转过来的,第一反应是切标注格式、转标签类别。多模态项目恰恰相反:最应先定的是“什么是同一个样本”。常见的做法是以一个句子或一次话轮为样本单元,一个样本同时关联一段文本、一段语音切片、一段视频片段和对应的情感标签。这是我的目录规划:
project/ ├─data/ │ ├─meta/ │ │ └─train.csv │ ├─text/ │ │ └─{sample_id}.txt │ ├─audio/ │ │ └─{sample_id}.wav │ ├─image/ │ │ └─{sample_id}_f0.jpg │ └─video_frame/ │ └─{sample_id}_f0.jpg ├─models/ ├─scripts/ └─outputs/train.csv 的字段依次是:sample_id、text_path、audio_path、img_path、label、start_ts、end_ts。start_ts 和 end_ts 是这段样本在原始视频或录音里的起止时间,用于切音频和抽帧。
为什么不用整个视频作为样本?训练时显存会被帧序列瞬间吃光,模型也学不到局部情绪变化。utterance 级样本的另一点好处是天然对齐了文本和语音:一个句子对应一段 wav,对应几帧画面。数据量不够时,可以先用公开中文多模态情绪数据集按这个结构重新落盘,再补自己的业务数据。
2.2 文本与语音的特征预处理:对齐是首要命题
文本模态最简单,我一般直接用预训练模型的 tokenizer。环境这块默认你已经按 Python 安装教程建好虚拟环境,python=3.10 配合 pytorch 2.x 即可。
# preprocess_text.py from transformers import BertTokenizer tokenizer = BertTokenizer.from_pretrained("bert-base-chinese") def encode_text(text: str, max_len: int = 128): encoded = tokenizer( text, max_length=max_len, truncation=True, padding="max_length", return_tensors="pt", ) return { "input_ids": encoded["input_ids"][0], "attention_mask": encoded["attention_mask"][0], }max_len 设 128 是因为绝大多数口语话轮不超过 60 个汉字,设太大会让小批量训练变慢。padding 选 max_length 而不是动态 pad,是为了后续和多模态特征拼接时省去处理变长序列的麻烦。如果你用短文本为主的业务(弹幕、客服短句),可以压到 64。
语音模态的坑稍多。常见做法是用 wav2vec2 类预训练模型提取深层特征,而不是手工 MFCC。加载时要处理三件事:重采样到 16kHz、转单声道、按样本的 start_ts/end_ts 切分。不要在模型里做切分,要在预处理里做,否则每次训练同一个样本都切一遍,浪费 I/O。
import torchaudio def load_audio_segment(path, start_sec, end_sec, target_sr=16000): waveform, sr = torchaudio.load(path) # 原始采样率 if sr != target_sr: waveform = torchaudio.functional.resample(waveform, sr, target_sr) if waveform.size(0) > 1: waveform = torch.mean(waveform, dim=0, keepdim=True) # 单声道 start = int(start_sec * target_sr) end = int(end_sec * target_sr) waveform = waveform[:, start:end] if waveform.size(1) == 0: # 切空兜底 waveform = torch.zeros(1, target_sr) return waveform注意 start_sec 在标注时如果用的是毫秒,这里记得除以 1000。切空的问题真实存在:VAD 边界标错、音频本来就短,切成 0 长度会让数据加载器直接崩。上面代码里的兜底逻辑属于必须写的那类防御代码。
2.3 图像与视频帧:离线抽帧,别在训练时解码
图像和视频模态容易被人低估,因为实践里的坑集中在“怎么抽帧”。正确做法是离线把所有关键帧抽好存成 jpg,而不是在 Dataset.getitem里用 OpenCV 按帧号跳读视频。在线读视频第一慢,第二遇到损坏视频文件时排错难。
ffmpeg -ss {start_ts} -to {end_ts} -i raw_video.mp4 -vf "fps=2,scale=224:224" -q:v 2 {sample_id}_%02d.jpg这段命令的含义:从 start_ts 到 end_ts 抽取片段,fps=2 表示每秒抽 2 帧,scale 直接缩放到 224x224,-q:v 2 是高质量 JPEG 编码。一个三秒的 utterance 通常会得到 6 帧左右。抽帧密度不用贪高,2fps 配上后面模型里的池化已经够用;帧太多会拖慢训练并且让同一段话的相邻帧高度重复。
抽完帧后,加载时可选策略有两种:一种是取每段中间帧作为静态图像特征,适合视频内容变化不剧烈的场景;另一种是把片段内所有帧都过图像编码器,再做时间维池化。前一种实现省事,后一种信息更全。建议先跑前一种做基线,模型收敛后再验证后一种是否真的带来收益。
预处理做完,四个模态已经变成了四种形状的张量:文本是 token id 序列,语音是一维波形或 wav2vec 输出序列,图像是 224x224x3,视频是若干帧的堆叠。接下来就要让这些张量变成同一维度空间里的向量。
3. 模态特征提取与融合策略:从单模态特征到对齐后的融合向量
3.1 预训练 backbone 怎么选:模态、输出维度与显存预算
这一节解决的是“四路输入分别用什么网络提特征”。先给结论:文本用 BERT 类模型取 [CLS];语音用 wav2vec2 加平均池化;图像用 CLIP 或者 ResNet 都可以;视频复用图像分支对每帧过一遍再池化。选型时盯住两个指标:输出维度和显存占用。
| 模态 | 常用编码器 | 输入形状 | 特征输出维度 | 显存占用参考 |
|---|---|---|---|---|
| 文本 | bert-base-chinese | 128 token | 768 | 约 1.5G |
| 语音 | wav2vec2-base | 波形采样点 | 768(平均池化后) | 约 1G |
| 图像 | vit-b/32 或 resnet50 | 224x224x3 | 768 或 2048 | 约 1G |
| 视频 | 图像编码器 + 时间池化 | 3~6 帧 | 768 | 与原帧数成正比 |
上表的显存是按 batch_size=8 粗估的,实际随序列长度浮动。注意语音 wav2vec2 输入是原始波形采样点数组,16kHz 下 5 秒音频就是 80000 个采样点,内存占用远大于文本序列。一般做法是 wav2vec2 输出后直接做时间维平均池化,得到一个固定的 768 维向量,后续就跟文本 [CLS] 向量地位相同。
统一输出维度这一步不能省。三个 768、一个 2048,直接拼接会让梯度被维度大的分支主导。我会在四个分支后面各接一个线性投影层,把输出统一映射到 hidden_dim=256。投影层参数很少,但能让后续融合层学得稳。
3.2 三种融合范式的取舍:早融合、晚融合、跨模态注意力
融合策略决定这个系统的上层建筑。先讲清三种方案的差异,再给你一个不用纠结的选择标准。
早融合就是把四个分支的输出向量直接拼接,过几层 MLP 后出分类结果。做法最直观,缺点是拼接后维度大、需要较多数据来拟合交叉项。数据量几千条时容易过拟合。
晚融合是每个模态单独训练或单独前向得到情感概率,再把概率加权求和。实现最简单,线上稳定,缺点是缺少模态间的交互——比如画面和文本相互矛盾这种关键信息它学不到。
跨模态注意力是让一个模态的表示去查询其他模态的表示。常见做法是用文本向量作为 query,语音和图像特征作为 key/value,让模型学习“说到生气这个词时,更看重语音里的愤怒程度”。效果好,但对数据量和调参要求都高。
我的选择顺序:先跑晚融合作为基线——它能告诉你四个模态单拎出来多强;再上早融合对比;最后在训练资源充足时试跨模态注意力。不要在项目第一天就上注意力,否则后面出了问题你分不清是融合的锅还是数据对齐的锅。
3.3 融合模型核心代码:一个可扩展的基座
下面给出一个可以直接改的融合模型。它支持三种模式,用 fusion_mode 参数切换。为了让代码可读,我把四个分支都用简单的投影层模拟,真实使用时替换成 BERT 和 wav2vec2 的输出即可。
import torch import torch.nn as nn import torch.nn.functional as F class MultimodalEmotionModel(nn.Module): def __init__(self, text_dim=768, audio_dim=768, image_dim=768, video_dim=768, hidden_dim=256, num_classes=7, fusion_mode="late"): super().__init__() self.fusion_mode = fusion_mode # 四个投影层,把不同维度统一到 hidden_dim self.text_proj = nn.Linear(text_dim, hidden_dim) self.audio_proj = nn.Linear(audio_dim, hidden_dim) self.image_proj = nn.Linear(image_dim, hidden_dim) self.video_proj = nn.Linear(video_dim, hidden_dim) self.drop = nn.Dropout(0.3) if fusion_mode in ("early", "attention"): # 早融合和注意力融合之后接同一个分类头 self.classifier = nn.Sequential( nn.Linear(hidden_dim * 4, hidden_dim), nn.ReLU(), nn.Dropout(0.3), nn.Linear(hidden_dim, num_classes), ) else: # 晚融合:各自出 logits,再做加权 self.classifiers = nn.ModuleList( [nn.Linear(hidden_dim, num_classes) for _ in range(4)] ) def forward(self, text_feat, audio_feat, image_feat, video_feat): # 各模态投影到同一空间 t = self.drop(F.relu(self.text_proj(text_feat))) a = self.drop(F.relu(self.audio_proj(audio_feat))) i = self.drop(F.relu(self.image_proj(image_feat))) v = self.drop(F.relu(self.video_proj(video_feat))) if self.fusion_mode == "early": fused = torch.cat([t, a, i, v], dim=-1) return self.classifier(fused) if self.fusion_mode == "late": logits = [] for proj_feat, clf in zip([t, a, i, v], self.classifiers): logits.append(clf(proj_feat)) return torch.stack(logits).mean(dim=0) # 简单平均 if self.fusion_mode == "attention": # 用文本向量作为 query,去 attend 其他三个模态 q = t.unsqueeze(1) # [B, 1, H] kv = torch.stack([a, i, v], dim=1) # [B, 3, H] attn_weight = torch.softmax(q @ kv.transpose(-2, -1) / (t.size(-1) ** 0.5), dim=-1) context = (attn_weight @ kv).squeeze(1) # [B, H] fused = torch.cat([t, context], dim=-1) return self.classifier(torch.cat([fused, t, a, i, v], dim=-1)[:, :hidden_dim*4])这段代码的关键在最后:跨模态注意力算出的 context 向量只保留了文本之外的模态被“聚焦”后的信息。不过老实说,这个简化版注意力在数据不足时很容易退化成对某个模态的固定加权,所以我把 early 和 attention 用同一个分类头,跑出来的分数差异小就说明融合没学到额外东西。
参数方面:hidden_dim 我取 256 而不是更大,是为了让 batch_size=16 下显存可控。dropout 0.3 是融合层防止过拟合的基准值,如果你发现训练 loss 下降正常但验证集震荡很大,先把它升到 0.5 再调别的。num_classes 按你的标签体系改:二分类就设 2,七分类(愤怒/厌恶/恐惧/开心/中性/悲伤/惊讶)就设 7。
4. 训练一个完整链路:数据加载、损失函数与关键参数
4.1 自定义 Dataset 与模态缺失 mask
模型有了,接下来解决“数据怎么一口一口喂给模型”。多模态 Dataset 与单模态最大的区别是要同时返回四个模态的特征和它们的缺失标记。缺失标记不是可选项——真实业务里经常出现某段语音无法解码、某个视频帧抽空的情况,训练时把缺失模态置零比直接抛异常要稳得多。
# dataset.py import torch from torch.utils.data import Dataset import pandas as pd import numpy as np class MultiModalDataset(Dataset): def __init__(self, meta_csv, text_dir, audio_dir, image_dir, video_dir): self.df = pd.read_csv(meta_csv) self.text_dir = text_dir self.audio_dir = audio_dir self.image_dir = image_dir self.video_dir = video_dir def __len__(self): return len(self.df) def __getitem__(self, idx): row = self.df.iloc[idx] # 读取文本,未找到时用空向量 + 缺失标记 text_feat = self._load_text(row["text_path"]) audio_feat = self._load_audio(row["audio_path"]) image_feat = self._load_image(row["img_path"]) video_feat = self._load_video(row["video_path"]) # 缺失标记:1 表示存在,0 表示缺失 mask = torch.tensor([ text_feat is not None, audio_feat is not None, image_feat is not None, video_feat is not None, ], dtype=torch.float32) text_feat = text_feat if text_feat is not None else torch.zeros(128, dtype=torch.long) audio_feat = audio_feat if audio_feat is not None else torch.zeros(16000) image_feat = image_feat if image_feat is not None else torch.zeros(3, 224, 224) video_feat = video_feat if video_feat is not None else torch.zeros(6, 3, 224, 224) label = torch.tensor(row["label"], dtype=torch.long) return { "text": text_feat, "audio": audio_feat, "image": image_feat, "video": video_feat, "mask": mask, "label": label, }在getitem里直接返回原始波形和图像张量,特征提取放到模型中做,这样改动 backbone 时不需要重写数据层。mask 在训练时还有另一个用途:随机把某个模态的 mask 置 0,模拟模态缺失,这一步等于免费的数据增强。做法是在 collate_fn 里以 0.1 的概率对每个样本随机抹掉一个模态,坚持用这个策略会让模型对缺失模态更鲁棒。
4.2 损失函数与训练参数:照着这份起步配置先跑起来
损失函数我建议用带标签平滑的交叉熵。多模态情感系统的标签主观性强,同一个语音片段不同标注者经常有分歧,硬标签容易让模型在边界样本上学出过度自信的预测。label_smoothing=0.1 等于告诉模型“不要百分百相信标注”,通常能换来验证集 F1 的稳定提升。
criterion = nn.CrossEntropyLoss(label_smoothing=0.1)训练参数给一份可直接复制的起步配置,以 batch_size=16、单卡 24G 显存为前提。BERT 这类预训练模型的主干部分学习率要调低,新加的投影层和分类头可以用较大学习率,这比全局用同一个 lr 收敛得更稳。
| 参数 | 起步值 | 说明 |
|---|---|---|
| batch_size | 16 | 显存不足就减到 8,别只用梯度累积硬撑 |
| 主干 lr | 2e-5 | BERT、wav2vec2 专用 |
| 新层 lr | 1e-4 | 投影层、融合层、分类头 |
| warmup_ratio | 0.1 | 前 10% 步数线性升温 |
| max_epochs | 20 | 配合早停使用 |
| early_stop_patience | 3 | 验证集 macro-F1 连续 3 个 epoch 不涨即停 |
| dropout | 0.3 | 融合层 |
| label_smoothing | 0.1 | 缓解标注歧义 |
warmup 的作用是防止预训练模型在第一步就被大梯度冲坏权重。如果你发现第一个 batch 的 loss 是 NaN,优先检查学习率和输入里有没有 NaN,而不是改模型结构。早停的 patience 设 3 已经够用,设太大容易过拟合,太小则容易停在半山腰。
4.3 训练主循环:mask 如何参与前向
训练循环里最容易被忽略的一步是 mask 怎么用。它的作用不是在 forward 里直接乘特征,而是在“应该把某个模态当缺失”时,把对应特征替换成零向量。下面是一个最小可跑的训练循环骨架。
# train.py def train_one_epoch(model, loader, optimizer, criterion, device): model.train() total_loss = 0.0 for batch in loader: text = batch["text"].to(device) audio = batch["audio"].to(device) image = batch["image"].to(device) video = batch["video"].to(device) mask = batch["mask"].to(device) label = batch["label"].to(device) # 训练时随机遮断一个模态,模拟缺失增强 if random.random() < 0.1: drop_idx = random.randint(0, 3) mask[:, drop_idx] = 0.0 # 这里假设模型 forward 能接收 mask 并做置零 logits = model(text, audio, image, video, mask) loss = criterion(logits, label) optimizer.zero_grad() loss.backward() optimizer.step() total_loss += loss.item() return total_loss / len(loader)mask 置零放在 forward 内部实现:当 mask 某个维度为 0 时,不将该模态的投影结果送入后续融合,而是用一个可学习的“缺失向量”替代。这样比直接填零更平滑。注意训练时的随机遮断比例不要超过 0.1,太高会让模型对真实模态也不信任,反而降低全模态输入时的准确率。验证和推理阶段则始终用真实 mask,不再做遮断。
5. 系统的落地避坑:多模态项目最常见的五个翻车点
5.1 加了语音和图像,效果反而不如单文本
现象:单文本模型的 F1 是 0.62,加上音频图像后掉到 0.58,融合仿佛在帮倒忙。
原因:这是多模态项目里最典型的“模态主导”问题。文本分支在数据里表达能力最强,早期训练阶段梯度几乎全部由文本分支贡献,语音和图像分支学不到有效表示,反而在融合层引入噪声。
解决:各分支先分别预训练或用冻结的预训练特征,再开融合训练;损失函数里为每个模态单独加一个辅助分类损失,让每个分支都先学会“自己该干什么”。另外把每个模态的投影输出做 LayerNorm,能缓解梯度大小不一致。
5.2 视频推理时显存溢出
现象:单条样本推理没问题,batch_size=16 一跑就 OOM,报错在视频编码器附近。
原因:视频分支一次性把 6 帧全部过图像编码器,显存占用是图像模态的 6 倍。很多人从文本任务转过来,对显存的估算还停留在“文本根本不吃显存”的惯性里。
解决:把视频帧离线编码成 768 维向量存成 npy 文件,训练时只读特征向量,不再过图像编码器。这个改动能把显存占用降到原来的十分之一。代价是视频编码器不能参与微调,但绝大多数场景下效果损失不明显。
5.3 切完的音频时长和文本对不上
现象:训练到一半发现一条样本的语音比文本长 10 秒,模型输出异常。
原因:原始标注的时间戳和语音转写结果不严格对齐,或者 VAD 切分的是整个对话里的一整句,而不是标签对应的那一句。
解决:在预处理阶段加一道校验函数,计算音频时长与文本字数的比值,超出合理区间(比如每字 0.1 到 0.6 秒)就丢弃或人工复核。宁可在数据清洗时多扔掉几条坏样本,也别让它们在训练时污染整个 batch。这类脏样本一般只占千分之几,但它的梯度扰动往往异常大。
5.4 训练 loss 稳步下降,但宏平均 F1 原地波动
现象:训练集 loss 从 2.1 降到 0.8,验证集 F1 却在 0.52 到 0.55 之间来回跳。
原因:多半不是模型问题,而是类别不平衡加评估指标不匹配。客服语料里“中性”占 70%,“愤怒”只占 3%,普通准确率虚高,F1 又被少数类拖住。
解决:训练用带类别权重的交叉熵,或者在采样器里对少数类别过采样;评估只看 macro-F1,不要看 accuracy。另一个容易被忽略的点是,验证集要按说话人或视频 ID 划分,否则同一个人的多条语音出现在训练和验证里,F1 会被虚高。
5.5 换了一台机器,以前跑通的数据集加载报错
现象:代码没动,数据没动,换到 Windows 机器后路径带中文,加载 wav 时直接抛 RuntimeError。
原因:多模态项目里中文路径是常见暗坑,Windows 默认编码不是 UTF-8,而音频、视频路径往往带着中文用户名或中文目录名。
解决:全项目统一用 pathlib.Path 处理路径,并在所有文件读取前强制 UTF-8:
import sys sys.stdout.reconfigure(encoding="utf-8") # Python 3.7+训练脚本开头加上这个设置,能规避大部分 Windows 下中文路径导致的文件读取异常。如果还是报错,检查 ffmpeg 抽帧命令里的路径是否被 shell 重新转码过。
6. 用交叉消融实验验证每个模态的价值:三张表看清系统边界
6.1 五分钟跑完所有模态组合:基于 itertools 的消融脚本
多模态项目上线前,我最先做的是不是调参,而是先跑一遍全组合消融。你想知道“视频输入到底加不加”“图像分支是不是在帮倒忙”,靠猜没有用,必须让每个组合在验证集上说话。这个脚本用 itertools 枚举 15 种非空组合,一次性输出 F1、ACC 和显存占用。
# ablate.py import itertools modalities = ["text", "audio", "image", "video"] with open("ablation_results.csv", "w") as f: f.write("subset,macro_f1,acc,peak_memory_MB\n") for r in range(1, 5): for subset in itertools.combinations(modalities, r): subset_name = "+".join(sorted(subset)) # 判断当前组合是否包含某个模态 active = [m in subset for m in modalities] # 用 active mask 过滤样本,训练模型并评估 model = build_model(active_mods=list(subset)) macro_f1, acc, peak_mem = run_evaluation(model, active) f.write(f"{subset_name},{macro_f1},{acc},{peak_mem}\n")脚本的思路:build_model 只保留 active_mods 对应的分支,run_evaluation 里按同样配置训练固定 epoch 并返回验证集指标。跑完全部组合之后,你会得到一张类似下面的表(数据来自一个通用口头对话场景,仅供参考格式):
| 模态组合 | macro-F1 | ACC | 峰值显存 |
|---|---|---|---|
| text | 58.2 | 0.66 | 5.1G |
| audio | 38.7 | 0.48 | 4.2G |
| image | 32.1 | 0.41 | 4.8G |
| text+audio | 61.4 | 0.69 | 7.3G |
| text+image | 60.0 | 0.68 | 7.6G |
| text+audio+image+video | 62.3 | 0.71 | 16.8G |
6.2 怎么读这张表:看增量,不看绝对值
读表顺序由大到小。先看“全模态 vs 最强单模态”,如果差距不到 1.5 个点,说明融合层没有真正利用多模态信息,回去查第 3.2 节的融合策略。再看“text+audio vs text”,这是判断“音频值不值得保留”的直接证据——增量超过 2 个点就值得为它维护一条音频预处理链路;增量小于 1 个点,可以在工程上把音频降级为可选模态。
有一个我踩过多次的教训:别因为单模态 audio 只有 38.7 就觉得音频没用。多模态融合的价值经常出现在“两个弱模态互相补强”的组合里。某次项目里 image 单独只有 32,video 单独只有 30,但 image+video 却到了 41——这背后是静态帧和动态时序正好互补。所以只看绝对分数会误杀有价值的模态,一定得看组合增量。
最终决策逻辑我习惯这样定:全模态能超过最佳子集 1 个点 F1,就保留所有链路;如果全模态成本和收益不成正比,就把验证集方差最大的那个模态切掉,降级成可选输入。每次改完模型或数据,重跑一次消融脚本,把结果存成 csv 留档。这套习惯帮我挡掉过好几次“感觉视频应该有用了”的拍脑袋改动,也希望帮到你。
本文还有配套的精品资源,点击获取