简介面向毕业设计与课程作业的多模态情感分析完整项目基于Python开发支持文本、语音、图像、视频四种输入覆盖数据预处理、多模态特征融合、模型训练与结果展示。压缩包共21个文件含5个Python脚本create_data.py数据处理、model.py模型构建、run.py运行入口、9个pickle预处理特征、MOSI/IEMOCAP数据集压缩包以及PDF/Markdown/TXT文档说明整体约56.86MB。已有112人浏览学习。代码注释详实文档覆盖数据集、模型结构与启动方式下载后简单配置即可运行附带result.png可视化结果便于核对情绪分类效果。整体目录层次清晰功能完整适合作为毕业设计或课程作业的高分参考方案。1. 多模态情感分析代码包四路输入不是把四个模型拼一块这套基于 Python 的多模态情感分析代码、文档说明和数据集是我拆过的资源里最适合毕业设计和课程作业落地的那一类。它同时接收文本、语音、图像和视频四种输入输出积极/中性/消极三类情感代码里完整覆盖数据预处理、单模态模型、融合层、训练脚本和推理脚本。很多人以为多模态就是把四个单模态模型的结果做个平均真跑起来你会被特征维度对不上、视频帧与语音错位、类别不平衡三个问题来回教育。资源附带的示例数据集规模不算大但四路输入都有对应文件和标签足够把主流程跑通再做消融实验。适合要用多模态撑起毕设创新点、又不想从零造数据和造代码的人。2. 数据与预处理四路输入的第一步是统一时间轴2.1 目录结构与标签格式拿到资源先别急着pip install先把 dataset 目录结构看清楚这份资源的划分是标准的 train/val/test 三段式dataset/ ├── train/ │ ├── text/ # 每段对话一个 utf-8 编码的 txt │ ├── audio/ # wav16kHz 单声道 │ ├── image/ # 关键帧 jpg224x224 │ └── video/ # mp48~10 秒片段 ├── val/ ├── test/ └── label.csvlabel.csv 是唯一的标签来源四路输入靠同一个 id 关联起来。读取的时候我建议把它当成一张宽表来读id,text_path,wav_path,img_path,mp4_path,label s001,train/text/001.txt,train/audio/001.wav,train/image/001.jpg,train/video/001.mp4,0 s002,train/text/002.txt,train/audio/002.wav,train/image/002.jpg,train/video/002.mp4,1label 的取值我按0消极, 1中性, 2积极来处理。如果你的课程作业要求五分类比如加入愤怒、惊讶只需要改最后的输出头和标签映射字典。这里有一个容易忽略的动作读取 label.csv 之后先按 id 去重。视频和图像数据经常出现同一段对话抽了多张关键帧的情况重复样本会让验证集指标虚高答辩时一查数据一眼假。提示任何一种模态的样本缺失时直接在 DataLoader 里丢弃该条不要用零向量硬补。硬补会让融合层学到全 0 就是某类的假特征干扰注意力权重。2.2 文本清洗与语音 MFCC 提取文本预处理我一般用 jieba 分词加停用词过滤这是课程作业里最稳的组合不需要一上来就上 BERT tokenizerimport jieba import re import numpy as np STOPWORDS set(open(stopwords.txt, encodingutf-8).read().split()) MAX_LEN 128 def clean_text(raw): raw re.sub(r\s, , raw).strip() tokens [] for w in jieba.cut(raw): w w.strip() if w and w not in STOPWORDS: tokens.append(w) return tokens[:MAX_LEN] def tokens_to_ids(tokens, vocab): return [vocab.get(w, vocab[UNK]) for w in tokens]这段代码有三个参数值得动手调。MAX_LEN设 64 到 128 即可短视频口播文本一般就几十个字设成 512 纯属浪费显存。STOPWORDS文件里一定要包含了、呢、吧、啊这类语气词网络口语文本里它们出现频率极高不过滤会让词表膨胀一倍。vocab从训练集构建记不住新词就把UNK兜底放进去第 5 章会专门讲 OOV 的坑。分词在 CPU 上就能跑但 jieba 首次加载要建词典批量处理时放在进程启动时加载一次别在循环里反复加载。语音侧用 librosa 提取 MFCC 作为输入特征。资源里的 wav 是 16kHz 单声道这个采样率对情感识别足够不需要重采样到 44.1kHzimport librosa def extract_mfcc(wav_path, sr16000, n_mfcc40, max_frames200): y, _ librosa.load(wav_path, srsr) mfcc librosa.feature.mfcc(yy, srsr, n_mfccn_mfcc) if mfcc.shape[1] max_frames: mfcc np.pad(mfcc, ((0, 0), (0, max_frames - mfcc.shape[1]))) else: mfcc mfcc[:, :max_frames] return mfcc # 返回 (40, 200) 的二维数组n_mfcc40比 librosa 默认的 20 多一倍能多保留说话人韵律信息对消极、积极这种带情绪起伏的类别更友好。max_frames200相当于把音频强制对齐到约 8 秒200 帧 × 约 40ms 帧移时长不够就右侧补零超了就截断。这里的细节是librosa.load会自动做重采样如果原始 wav 不是 16kHzsr参数也要跟着传否则 MFCC 的时间分辨率会整个算错。2.3 图像与视频帧采样图像输入很简单读 resized jpg 就行视频输入才是重头戏。我的习惯是均匀采样而不是随机采样保证每条样本覆盖整段视频的内容分布import cv2 import numpy as np def sample_frames(video_path, num_frames8, target_size(224, 224)): cap cv2.VideoCapture(video_path) total int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) if total 0: cap.release() return None indices set(np.linspace(0, total - 1, num_frames).astype(int)) frames, cur [], 0 while True: ret, frame cap.read() if not ret: break if cur in indices: frames.append(cv2.resize(frame, target_size)) cur 1 cap.release() return np.stack(frames) # (8, 224, 224, 3)num_frames8是显存和信息量的折中太少丢动作信息太多训练时 batch 直接撑爆。target_size224必须和图像分支 MobileNetV2 的输入对齐有人会把图像配成 256、视频配成 224跑到第三轮就报 shape 错误。这里用cap.read()逐帧判断是最笨但最可靠的方法cap.set(CAP_PROP_POS_FRAMES, idx)看起来省事但部分解码器 seek 后返回的帧并不可靠我在这上面翻过车。3. 单模态模型搭建文本、语音、图像各用什么样的网络3.1 文本TextCNN 基线与 BERT 微调两条路线文本分支我用 TextCNN 打底这是课程作业里性价比最高的结构词嵌入后面挂三个不同宽度的卷积核相当于同时看 2-gram、3-gram、4-gram 的局部特征import torch import torch.nn as nn import torch.nn.functional as F class TextCNN(nn.Module): def __init__(self, vocab_size, embed_dim100, num_classes3): super().__init__() self.embed nn.Embedding(vocab_size, embed_dim, padding_idx0) self.convs nn.ModuleList([ nn.Conv1d(embed_dim, 128, k) for k in (2, 3, 4) ]) self.fc nn.Linear(128 * 3, 128) def forward(self, x): # x: [batch, seq_len] emb self.embed(x).permute(0, 2, 1) # [batch, embed_dim, seq_len] pooled [] for conv in self.convs: out F.relu(conv(emb)) out F.max_pool1d(out, conv.kernel_size[0]).squeeze(2) pooled.append(out) feat torch.cat(pooled, dim1) # [batch, 384] return self.fc(feat) # 128 维文本特征分类头放融合层三个卷积核输出通道都是 128拼接成 384 维再压到 128 维作为文本特征。padding_idx0必须有否则长度不足 128 的文本补齐之后0 位置同样参与梯度回传相当于给模型喂了大量无意义梯度。如果你对准确率要求更高可以换 BERT 微调路线用transformers库的AutoModel取last_hidden_state[:, 0]作为句子向量。代价是显存占用从几百 MB 涨到 2GB 以上还要额外下载预训练权重毕设时间紧的话我不建议走这条线。3.2 语音MFCC 轻量卷积网络语音分支直接把 40×200 的 MFCC 矩阵当成灰度图做二维卷积让卷积核去学频谱上的局部模式class AudioCNN(nn.Module): def __init__(self, n_mfcc40, num_classes3): super().__init__() self.conv1 nn.Sequential( nn.Conv2d(1, 32, 3, padding1), nn.BatchNorm2d(32), nn.ReLU(), nn.MaxPool2d(2) ) self.conv2 nn.Sequential( nn.Conv2d(32, 64, 3, padding1), nn.BatchNorm2d(64), nn.ReLU(), nn.MaxPool2d(2) ) self.fc nn.Linear(64 * 10 * 50, 128) def forward(self, x): # x: [batch, 40, 200]补一个通道维 x self.conv2(self.conv1(x.unsqueeze(1))) x x.view(x.size(0), -1) return F.relu(self.fc(x)) # 128 维音频特征两轮下采样把 40×200 压到 10×50展平后 32000 维全连接参数略大但可接受。两个关键点第一每层卷积后面必须跟 BatchNorm音频特征分布漂移大不加的话 loss 会抖动得像心电图第二ReLU放在fc前面分类头交给融合层统一处理。如果样本音频超过 10 秒建议把fc换成 LSTM用标准的nn.LSTM(64, 128, batch_firstTrue)取最后时刻输出比直接压平更能建模时序关系代价是训练时间变长。3.3 图像与视频MobileNetV2 与帧级特征聚合图像分支直接用 torchvision 里的 MobileNetV2 预训练权重这是最省事的做法也是大家检索mobilenetv2 代码时最常用的落地路径from torchvision import models def build_image_encoder(pretrainedTrue, freeze_backboneTrue): model models.mobilenet_v2( weightsmodels.MobileNet_V2_Weights.IMAGENET1K_V1 if pretrained else None ) model.classifier[1] nn.Linear(model.last_channel, 256) if freeze_backbone: for p in model.features.parameters(): p.requires_grad False return modelmodel.last_channel是 MobileNetV2 主干最后一层的输出维度固定是 1280我把分类头换成 256 维特征层。freeze_backboneTrue表示冻结 ImageNet 预训练主干只训练最后两层。这个开关对小数据集极其重要——解冻全部参数后几千条样本大概率过拟合val 准确率反而跌下去。视频分支在图像编码器外层包一个帧级聚合把 8 帧分别过编码器再取平均class VideoEncoder(nn.Module): def __init__(self, image_encoder): super().__init__() self.encoder image_encoder def forward(self, frames): # frames: [batch, 8, 3, 224, 224] b, t, c, h, w frames.shape feat self.encoder(frames.view(b * t, c, h, w)) # [b*8, 256] return feat.view(b, t, 256).mean(dim1) # [b, 256]平均池化的含义是整段视频的整体情绪对三分类够用。想更精细可以改成max(dim1)或者接一个 1×1 卷积学权重但那就引入了额外超参数。对课程作业来说先跑通均值版本再谈优化顺序不能反。4. 多模态融合与训练特征对齐、注意力加权和损失平衡4.1 特征维度对齐与注意力融合三个单模态模型输出的特征维度不一样文本 128、语音 128、图像/视频 256。融合前先统一投影到 128 维再用多头注意力让模型自己决定这次判断主要听谁的class MultimodalFusion(nn.Module): def __init__(self, text_dim128, audio_dim128, video_dim256, num_classes3): super().__init__() self.text_proj nn.Linear(text_dim, 128) self.audio_proj nn.Linear(audio_dim, 128) self.video_proj nn.Linear(video_dim, 128) self.attn nn.MultiheadAttention(embed_dim128, num_heads4, batch_firstTrue) self.classifier nn.Linear(128, num_classes) def forward(self, text_feat, audio_feat, video_feat): t self.text_proj(text_feat).unsqueeze(1) # [b, 1, 128] a self.audio_proj(audio_feat).unsqueeze(1) v self.video_proj(video_feat).unsqueeze(1) seq torch.cat([t, a, v], dim1) # [b, 3, 128] attn_out, attn_weight self.attn(seq, seq, seq) fused attn_out.mean(dim1) # [b, 128] return self.classifier(fused), attn_weightMultiheadAttention的batch_firstTrue让输入形状直接是[batch, seq_len, embed_dim]三个模态就是长度为 3 的序列。attn_weight我单独返回第 6 章会拿它做可解释性展示——答辩时能说清楚模型学了什么权重比光报准确率有用得多。如果你不想用注意力退一步用torch.cat([t, a, v], dim1)接全连接也行但特征尺度不一致时拼接融合很容易被大数值特征主导所以至少要有投影层。4.2 训练参数与损失设计训练配置我直接给一份能照抄的参数表这份配置跑通概率最高参数推荐值说明batch_size16单卡 8GB 显存可跑视频分支是显存大头optimizerAdamWlr 1e-4weight_decay 1e-2schedulerCosineAnnealingLR每个 epoch 余弦衰减到接近 0epochs30配合 early stoppingpatience5label smoothing0.1防止三分类置信度过饱和类别权重inverse frequency从训练集统计抑制多数类情感数据集里消极样本往往偏多三分类很容易训成全猜消极的高准确率模型。我拿到资源的第一件事就是数一遍各类样本数量然后把torch.tensor([w0, w1, w2])传给CrossEntropyLoss(weight...)。weight必须在loss.backward()之前归一化否则初始 loss 就是几百直接梯度爆炸。4.3 完整训练脚本与推理数据加载器把三种模态的预处理串起来训练主循环长这样def train_one_epoch(model, loader, optimizer, criterion, device): model.train() total_loss, correct, total 0.0, 0, 0 for text, audio, frames, labels in loader: text, audio, frames, labels ( text.to(device), audio.to(device), frames.to(device), labels.to(device) ) logits, _ model(text, audio, frames) loss criterion(logits, labels) optimizer.zero_grad() loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) optimizer.step() total_loss loss.item() correct (logits.argmax(1) labels).sum().item() total labels.size(0) return total_loss / len(loader), correct / totalclip_grad_norm_(max_norm1.0)这行别删多模态模型的梯度幅度差异比单模态大得多不裁剪的话融合层经常在第三个 epoch 训飞。训练时每个 epoch 打印一次 val 指标如果前 3 个 epoch val loss 不降反涨先别怀疑模型去查 DataLoader 是不是把 train 和 val 的预处理写成了两套逻辑——这是多模态项目里最容易埋雷、也最隐蔽的地方。注意val 指标要跨 epoch 存最优权重别用最后一个 epoch 的参数直接交作业。early stopping 触发的 epoch 往往比最后一个 epoch 效果好两到三个点。推理脚本就简单多了加载最优权重后直接跑import torch.nn.functional as F def predict(model, sample, device): model.eval() with torch.no_grad(): logits, _ model(sample[text], sample[audio], sample[frames]) prob F.softmax(logits, dim1) return prob, prob.argmax(dim1).item()prob返回三类置信度作业展示时可以直接画柱状图。torch.no_grad()推理时一定要加否则显存会保存整个计算图小显存机器跑一条样本也能 OOM。5. 避坑多模态情感分析最常见的五个坑5.1 视频帧和语音时间轴错位现象单模态语音、图像模型都能跑到 70% 左右准确率合到多模态以后 loss 曲线抖动整体准确率还不如单模态。原因我最初用 linspace 均匀取 8 帧语音则按全局时长 pad 到 200 帧两条时间轴根本没对齐。视频第 3 秒的画面被抽出来配到第 1 秒的语音上模型在学错位特征。解决统一以语音的时间轴为准。采样时先记录每个视频的 fps 和时长按frame_idx int(target_time * fps)精确取帧而不是均匀编号。DataLoader 里返回一个duration_sec字段所有模态都从它推导索引。5.2 测试集出现训练没见过的词OOV现象训练时 loss 正常一到验证就报IndexError: index out of range in self。原因词表只从训练集构建验证集里的新词找不到索引而vocab里又没有UNK兜底。解决构建词表时把PAD、UNK固定放在 vocab 前两个位置tokens_to_ids用vocab.get(w, vocab[UNK])兜底。另外把出现次数小于 3 的词直接映射到UNK能明显缓解过拟合。5.3 视频分支把显存吃爆现象batch_size16 一启动就CUDA out of memory。图像分支能跑视频分支必炸。原因8 帧视频展开成[batch*8, 3, 224, 224]相当于一次过 128 张图任何 8GB 卡都扛不住。解决batch_size 砍到 8帧数砍到 4先验证流程通再逐步加。DataLoader 的num_workers设 4 以上让帧采样在 CPU 上完成而不是 GPU 训练时现算。如果还炸就上torch.cuda.amp半精度训练显存直接省一半。5.4 融合后效果反而下降现象三个单模态准确率分别是 78/71/69拼接融合后只有 65多模态还打不过单模态。原因这不是玄学是特征尺度问题。文本特征是 128 维小数值向量图像特征是 MobileNetV2 输出的大数值向量直接拼接后全连接层几乎只看图像分支。解决每个模态先过各自的线性投影层再拼接或者投影后加LayerNorm。更稳的做法是晚融合先单独训练三个单模态模型加载进来冻结主干只微调融合层。5.5 Windows 下 import 报错与 Python 环境问题现象按 README 装完 requirementsimport librosa直接报OSError: cannot load library libsndfile或者import cv2报由于找不到 msvcp140.dll无法继续执行代码。原因librosa 依赖 libsndfile 动态库Windows 下 conda 默认通道经常装出损坏版本msvcp140.dll缺失是系统没装 Visual C 运行库跟 Python 版本无关。解决Windows 用户优先conda install -c conda-forge librosa soundfile别用 pip 裸装。msvcp140.dll的问题装一次 VC 2015-2022 Redistributable 就解决。如果装的是 Python 3.10 以下的老版本建议严格按文档里的 requirements.txt 锁定版本升到 3.11 之后部分 torchvision 接口名会变示例代码要跟着改。6. 进阶验证用消融实验和混淆矩阵让结果可解释6.1 消融实验该怎么做答辩时老师最常问的就是你这四个模态到底哪个起作用了。我建议直接把消融实验跑一轮结果表格长这样输入组合AccF1仅文本78.476.2仅语音71.369.7仅图像/视频68.866.4文本语音81.580.1三模态融合84.283.0数字是示例跑出来有波动正常关键是趋势融合大于任意单模态且文本贡献最大、视频贡献最小。这个结论能直接支撑为什么要做多模态的论述比堆一页网络结构图有说服力得多。6.2 混淆矩阵与注意力权重把测试集预测结果画成混淆矩阵一眼就能看出错误集中在哪import seaborn as sns import matplotlib.pyplot as plt from sklearn.metrics import confusion_matrix def plot_confusion(y_true, y_pred, labels): cm confusion_matrix(y_true, y_pred) sns.heatmap(cm, annotTrue, fmtd, cmapBlues, xticklabelslabels, yticklabelslabels) plt.xlabel(pred) plt.ylabel(true) plt.tight_layout() plt.savefig(confusion_matrix.png, dpi150)三分类情感里最常见的是 neutral 被另外两类吞掉因为中性表达本来就没有明显情绪词。遇到这种情况不用慌把它当成分界线来分析消极和积极被误判成中性比这两类相互误判更容易让老师接受。注意力权重直接打印融合层attn_weight的均值看模型对每条样本更信文本还是语音挑几条典型样本贴进报告里整个工作的完整度立刻就上来了。从那以后我每次交作业都会强制走一遍同样的验证流程先跑 DataLoader 的 sanity check确认四路输入 shape 全对再跑 5 个 epoch 看 loss 曲线是否正常下降最后才上完整训练和消融。这套习惯帮我挡掉过至少三次翻车希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?