
简介这是一份基于Jupyter与Python实现的多模态情感分析模型项目面向计算机、人工智能、通信等专业学生及从业者可作为期末课程设计或毕业设计的参考方案。项目为个人大作业答辩评审分达98分包含完整源码、报告文档与训练好的模型代码均已调试测试可直接运行并附有测试文本和预测结果便于对照验证。资源包内共2000个文件主要为1个Python主程序、1个README说明文档以及1998个文本数据文件整体压缩包约202.69MB目录结构清晰便于从数据加载、特征融合到模型训练与情感预测的完整链路进行学习。已有187人浏览学习适合从入门到进阶逐步掌握多模态情感分析建模也可在现有框架上二次开发扩展至不同应用场景满足个性化需求。1. 这个期末大作业到底在做什么多模态情感分析的最小闭环一条十五秒的短视频评论摆在面前字幕写着「无语」语气却带着笑画面里的人眉毛挑得很高。只读文本模型会被带偏只看画面又丢了语义和语气。把文本、音频、视频三种信号一起送进模型判断情绪倾向这就是多模态情感分析。用 Jupyter Python 做这件事是基于一个现实这个领域的大部分实验工作都在 notebook 里完成逐格调试、边跑边看中间特征比一上来就写工程化脚本高效得多。标题里的源码、报告文档和模型权重正好对应期末大作业的完整交付物跑得通的代码、能讲清楚的文档、以及一个真正训练出来的模型。这套最小闭环适合三类人要交课程设计或毕业设计的学生、想快速验证多模态思路的算法工程师、以及第一次接触多模态融合的初学者。2. 先让三种模态对齐文本、音频、视频的预处理路线多模态模型的第一步不是建模而是让三种异构数据变成模型能吃的同构向量。文本是一串 token音频是一段波形视频是一堆帧它们的时间粒度、数值范围和语义密度完全不同。预处理阶段的目标只有一个把这三个来源的特征各自固定成形状已知的张量并且保证它们在样本维度上严格一一对应。这个环节做不好后面模型怎么调都救不回来。2.1 为什么用 Jupyter Python 搭这套环境Jupyter 在这个项目里的角色不是摆设而是调试多模态特征的利器。我第一次做的时候最痛苦的不是写模型而是不确定某个视频帧到底提没提取对、音频对齐到哪一秒。notebook 里可以随时插一个 cell 打印特征形状、可视化 log-mel 频谱图、甚至把某一条样本的三个模态特征直接画出来比对。这种「边跑边看」的交互方式在特征工程阶段比任何 IDE 都好用。环境方面常见做法是用 conda 建一个独立环境避免和系统 Python 打架。Python 版本选 3.10 或 3.11 都行核心依赖是 torch、transformers、librosa、opencv-python、scikit-learn最后加一个 gradio 做演示。装包的命令很简单但建议把版本锁定尤其是 torch 和 transformers 的大版本要匹配否则可能遇到算子不兼容的怪问题。# 创建环境并安装核心依赖 conda create -n mmsa python3.10 -y conda activate mmsa pip install torch transformers librosa opencv-python scikit-learn gradio jupyter这套环境装完之后所有特征提取和训练代码都跑在 notebook 里。有一个小习惯值得养成每个模态的特征提取单独放一个 cell提取完立刻存成.npy文件不要每次都重新算一遍。视频提特征很慢跑一次十几分钟存盘之后后面反复实验就秒读了。2.2 文本模态BERT tokenizer 与序列截断文本模态在三个模态里信噪比最高所以通常用预训练语言模型来编码。BERT 是默认选择因为它对短文本的情感语义理解成熟而且在 Jupyter 里加载也就是两行代码的事。需要注意的是做文本预处理时不要自己写分词直接用AutoTokenizer它会处理好大小写、标点和未知词。from transformers import AutoTokenizer # bert-base-uncased 对英文短文本足够uncased 表示不区分大小写 tokenizer AutoTokenizer.from_pretrained(bert-base-uncased) text this movie is actually not bad text_ids tokenizer( text, max_length64, # 表情文本一般很短64 足够 paddingmax_length, # 统一 pad 到 64方便组 batch truncationTrue, # 超过 64 直接截断 return_tensorspt, # 返回 PyTorch tensor ) print(text_ids[input_ids].shape) # (1, 64) print(text_ids[attention_mask].shape) # (1, 64)这里的max_length64是一个值得解释的参数。视频评论字幕通常不超过十几词BERT 的默认最大长度是 512但用 512 意味着每个 batch 都要处理大量无意义的 pad token白白浪费显存和计算时间。64 是经验值既能覆盖绝大多数短视频评论又不会让序列太长。如果你做的数据集里出现过长的文本可以统计一下训练集的文本长度分布取 95 分位数作为max_length这是比拍脑袋更靠谱的做法。attention_mask不是可选项它告诉 BERT 哪些位置是真实 token、哪些是 padding。后面把input_ids和attention_mask一起喂给模型的时候漏掉 mask 会让模型把 padding 也当成语义内容训练出来的表征会偏。2.3 音频模态log-mel 频谱池化与 opensmile 替代方案音频模态有两个主流特征路线手工声学特征opensmile 提取的 6373 维特征和频谱特征log-mel。opensmile 特征在传统情感识别里是标配维度高、信息密但安装和配置麻烦特征含义也不直观。log-mel 频谱用 librosa 几行就能算出来可视化方便配合池化后得到的低维向量对这个任务来说足够了。我的建议是期末大作业用 log-mel 池化方案跑通全流程如果想冲高分再把手工特征换进去做对比实验。import librosa import numpy as np def extract_audio_feature(audio_path, sr16000, n_mels64, max_frames320): # 统一采样率到 16k情感语音的绝大部分能量集中在 8k 以下 y, _ librosa.load(audio_path, srsr) mel librosa.feature.melspectrogram( yy, srsr, n_melsn_mels, fmax8000 ) log_mel librosa.power_to_db(mel, refnp.max) # (64, T) # 时间帧对齐超过 max_frames 截断不足则补零 if log_mel.shape[1] max_frames: log_mel log_mel[:, :max_frames] else: pad_width max_frames - log_mel.shape[1] log_mel np.pad(log_mel, ((0, 0), (0, pad_width))) # 时间维池化均值 标准差得到 2 * n_mels 128 维向量 feat np.concatenate([ log_mel.mean(axis1), log_mel.std(axis1), ]) return feat.astype(np.float32) # 使用示例 audio_feat extract_audio_feature(sample.wav) print(audio_feat.shape) # (128,)这段代码里有几个参数要单独说。sr16000是对齐的基石很多视频的音轨是 44.1k 或 48k不统一采样率会导致同一句话在不同视频里长度不一致。fmax8000是梅尔滤波器的上限频率人的语音能量几乎都集中在 8k 以下往上都是噪声和乐器高频滤掉能让模型更专注在说话语气上。n_mels64决定频谱的分辨率64 是一个平衡点再大特征维度过高再小会丢失音调细节。max_frames320对应 16k 采样率下约 2 秒的音频长度。短视频的情感表达通常在开头几秒就完成了2 秒覆盖绝大多数情况。如果你的数据里有超过 2 秒的音频这个参数要放大否则后面的内容全被截掉。最后用均值和标准差在时间维做池化本质上是把一段频谱压成 128 维的统计量虽然丢了时序细节但对情感这种低频语义来说统计量已经能捕捉「语速快慢」和「音调起伏」的大致模式了。2.4 视频模态均匀采样 ResNet18 提帧特征视频是三个模态里计算量最大的原视频直接送进模型既不现实也没必要。情感表达在视频里主要通过面部表情和肢体动作体现这些是低频信号不需要每秒 30 帧的精细度。常见做法是均匀抽 16 帧再用预训练 CNN 提特征代表作是 ResNet18 这种轻量网络。这里借用 ImageNet 预训练权重不需要微调因为我们的任务不是识别物体而是拿到每帧图像的通用语义特征。import cv2 import numpy as np import torch import torchvision # 加载去掉了全连接层的 ResNet18输出 512 维特征 def build_video_extractor(): resnet torchvision.models.resnet18( weightstorchvision.models.ResNet18_Weights.IMAGENET1K_V1 ) extractor torch.nn.Sequential(*list(resnet.children())[:-1]) extractor.eval() return extractor extractor build_video_extractor() def extract_video_feature(video_path, num_frames16): cap cv2.VideoCapture(video_path) total int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) if total 0: raise ValueError(f无法读取视频: {video_path}) # 在时间轴上均匀取 16 个位置 frame_ids np.linspace(0, total - 1, num_frames).astype(int) frames [] for fid in frame_ids: cap.set(cv2.CAP_PROP_POS_FRAMES, int(fid)) ret, frame cap.read() if not ret: frame np.zeros((224, 224, 3), dtypenp.uint8) frame cv2.resize(frame, (224, 224)) frame cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) frames.append(frame) cap.release() # (16, 224, 224, 3) - (16, 3, 224, 224) - (16, 512) x torch.from_numpy(np.stack(frames)).permute(0, 3, 1, 2).float() / 255.0 with torch.no_grad(): feat extractor(x).squeeze(-1).squeeze(-1) # (16, 512) # 对 16 帧做平均得到整个视频的 512 维特征 return feat.mean(dim0).numpy() video_feat extract_video_feature(sample.mp4) print(video_feat.shape) # (512,)num_frames16是经过实践检验的数值太少了抓不住表情变化太多则计算量翻倍但效果提升有限。提取视频特征时一定要包在torch.no_grad()里因为 ResNet 只是特征提取器不需要反向传播不关梯度的话显存会被撑爆。如果视频读帧失败代码里用全零帧兜底这在脏数据较多的视频数据集里很常见宁可补零也别让整个训练流程中断。到这里三个模态的特征维度分别是文本 64 个 token 由 BERT 处理、音频 128 维、视频 512 维。预处理完成后建议把每个样本的三个特征存成一个字典序列用 pickle 或 numpy 保存训练时直接从磁盘读不再碰原始视频文件。3. 搭建融合模型双塔编码 多头注意力把三种特征捏在一起预处理做完手里有了三个模态的向量接下来的核心问题是怎么把它们融合成一个情感判断。这里说的融合不是简单拼起来而是要让模型自己学会三个模态各自该信多少。很多第一次做多模态的人会踩进一个误区——把特征直接concat成一个长向量丢给分类器结果发现效果还不如只用文本。原因在于模态之间的数值尺度不匹配以及没有建模模态间的交互。这一章把融合模型讲透从选型到代码逐层拆开。3.1 为什么中间融合优于早融合和晚融合多模态融合按位置分三派。早融合是先把原始特征拼起来再进模型问题是文本的 768 维、音频的 128 维、视频的 512 维直接拼成 1408 维各个维度的量纲和语义密度完全不同模型很难学到合理的权重分配。晚融合是每个模态单独训练一个模型最后把预测结果投票或平均实现简单但模态间的交互信息彻底丢失——比如画面里一个无奈的笑单独看画面是正向单独听语气是负向只有同时看到两者才知道这是「无奈」。中间融合是更稳妥的方案每个模态先各自编码到同一个隐空间再通过注意力机制在隐空间完成融合。这样做的好处是三种特征在融合前已经被映射到了可比的维度注意力能够学到「当前样本主要靠哪个模态做判断」。这个思路和 Transformer 里的 self-attention 本质一致可以把它理解成一个微型的跨模态 Transformer 层三个模态作为三个 token互相计算相关性权重大的模态主导最终判断。3.2 文本编码器加载 BERT 的冻结与微调两种选择文本编码器直接用 HuggingFace 的AutoModel加载但加载之后要做一个关键决策冻结还是微调。冻结 BERT 的意思是参数不参与反向传播只当特征提取器用好处是显存占用小、训练稳定、速度快微调则让 BERT 参数跟着任务更新理论上能学到更贴合情感语义的文本表示但显存需求高学习率没设好容易灾难性遗忘。from transformers import AutoModel class TextEncoder(torch.nn.Module): def __init__(self, freeze_bertTrue, hidden_dim256): super().__init__() self.bert AutoModel.from_pretrained(bert-base-uncased) if freeze_bert: for param in self.bert.parameters(): param.requires_grad False # BERT 的 pooler 输出是 768 维投影到 256 维 self.proj torch.nn.Linear(768, hidden_dim) self.dropout torch.nn.Dropout(0.2) def forward(self, input_ids, attention_mask): out self.bert(input_idsinput_ids, attention_maskattention_mask) pooled out.pooler_output # (B, 768) return self.dropout(self.proj(pooled)) # (B, 256)期末大作业这个量级的数据集我建议直接冻结 BERT把省下来的显存留给视频特征和融合层。微调 BERT 只有在数据量足够大、且文本确实是任务核心模态时才值得。选pooler_output而不是last_hidden_state的均值是因为 BERT 的 pooler 输出已经经过了 tanh 变换专门为分类任务设计的语义向量直接拿来用更省事。3.3 音频视频投影到同一隐空间音频和视频没有预训练语言模型那么强的语义表征常见做法是先用 MLP 把它们各自映射到与文本相同的 256 维隐空间。重点在于 MLP 的第一层之前要加LayerNorm这一步很多人会忽略。音频的 128 维特征和视频的 512 维特征数值范围差异很大不归一化的话 MLP 前几层会被大数值特征主导更新不稳定。import torch.nn as nn def build_projection(input_dim, hidden_dim256): return nn.Sequential( nn.LayerNorm(input_dim), # 先归一化再映射 nn.Linear(input_dim, hidden_dim), nn.ReLU(), nn.Dropout(0.2), ) audio_proj build_projection(128) video_proj build_projection(512)投影层的设计有意做成和文本一样的输出维度 256这个一致性是后面注意力融合的前提。如果三个模态的输出维度不一样torch.stack都做不了。把维度统一到 256 而不是 768是因为 256 维足够容纳情感分类所需的语义信息同时让注意力层的参数量降到可接受范围。3.4 融合层与分类头注意力加权完整代码融合层用nn.MultiheadAttention把三种模态看成序列长度为 3、嵌入维度为 256 的输入。注意力在这里的作用是计算「文本、音频、视频」两两之间的相关度输出是一个加权后的融合向量。对情感分类来说一个样本里可能文本权重 0.6、音频 0.3、视频 0.1换个样本可能视频权重更高这种动态加权是固定权重融合做不到的。import torch import torch.nn as nn from transformers import AutoModel class MultimodalSentimentModel(nn.Module): def __init__(self, text_dim768, audio_dim128, video_dim512, hidden_dim256, num_classes2, freeze_bertTrue): super().__init__() # 文本编码器 self.bert AutoModel.from_pretrained(bert-base-uncased) if freeze_bert: for param in self.bert.parameters(): param.requires_grad False self.text_proj nn.Sequential( nn.Linear(text_dim, hidden_dim), nn.ReLU(), nn.Dropout(0.2), ) # 音频、视频投影 self.audio_proj nn.Sequential( nn.LayerNorm(audio_dim), nn.Linear(audio_dim, hidden_dim), nn.ReLU(), nn.Dropout(0.2), ) self.video_proj nn.Sequential( nn.LayerNorm(video_dim), nn.Linear(video_dim, hidden_dim), nn.ReLU(), nn.Dropout(0.2), ) # 跨模态注意力融合 self.fusion_attn nn.MultiheadAttention( embed_dimhidden_dim, num_heads4, batch_firstTrue, ) # 分类头 self.classifier nn.Linear(hidden_dim, num_classes) def forward(self, input_ids, attention_mask, audio_feat, video_feat): # 文本BERT - 投影 bert_out self.bert(input_idsinput_ids, attention_maskattention_mask) text_feat self.text_proj(bert_out.pooler_output) # (B, 256) # 音频、视频各自投影到同一隐空间 audio_feat self.audio_proj(audio_feat) # (B, 256) video_feat self.video_proj(video_feat) # (B, 256) # 堆成序列交给注意力层 feats torch.stack([text_feat, audio_feat, video_feat], dim1) # feats: (B, 3, 256)等价于序列长度 3 的 token 序列 attn_out, attn_weight self.fusion_attn(feats, feats, feats) # attn_out: (B, 3, 256), attn_weight: (B, 3, 3) # 对注意力输出做平均池化得到整个样本的融合表示 fused attn_out.mean(dim1) # (B, 256) logits self.classifier(fused) # (B, num_classes) return logits, attn_weight这段代码就是完整模型。前向流程分四步文本过 BERT 拿池化向量并投影音频视频各过自己的 MLP三者stack成序列过注意力最后平均拿到融合表示再过分类头。返回attn_weight是为了方便分析调试打印一眼就能看出来当前样本主要靠哪个模态做判断这个信息写报告的时候非常有用。nn.MultiheadAttention的batch_firstTrue意味着输入形状是(B, seq_len, dim)这里seq_len是 3表示三个模态。num_heads4是常用的经验值256 维嵌入被 4 个头平分每个头 64 维足够表达不同模态之间的交互模式。刻意不给注意力加 mask是因为在这个设计里三个模态都是有效输入如果以后要处理模态缺失的场景可以在forward里加key_padding_mask把缺失模态的位置屏蔽掉这是这个架构的天然扩展点。4. 训练与调参从 loss 震荡到稳定收敛的完整流程模型定义好了接下来是训练。多模态模型的训练和单模态有一个显著差别——不同模块的学习率要分开设。BERT、MLP 投影层、注意力融合层、分类头这四部分的收敛速度差异很大用同一个学习率要么导致 BERT 崩溃要么导致 MLP 学不动。这一章把损失函数、训练循环、必调参数和工程化跑法一次讲清楚。4.1 损失函数与评估指标怎么搭配情感分析任务有两种常见形式分类和回归。分类就是把情感分成积极/消极或者积极/中性/消极三类用CrossEntropyLoss回归是给情感打分比如 MOSI 数据集的 -3 到 3 的连续分数用MSELoss或L1Loss。期末大作业推荐从二分类入手结果指标用准确率和 F1容易讲清楚如果数据集自带连续情感分数再同时汇报 MAE 和相关系数会显得更专业。import torch.nn as nn # 二分类任务 criterion nn.CrossEntropyLoss() # 如果需要处理类别不均衡给少数类更高权重 # criterion nn.CrossEntropyLoss(weighttorch.tensor([1.0, 2.0]))类别不均衡是情感数据集的常见问题负面评论往往比正面评论多。如果训练集里两个类别的比例超过 2:1就给CrossEntropyLoss传入weight参数否则模型会倾向把所有样本都预测成多数类准确率看着不低F1 却很难看。这个权重在训练前看一眼标签分布就能定下来不需要花哨的方法。4.2 训练循环早停、梯度裁剪与学习率调度训练循环的关键不在loss.backward()那几行而在训练策略。模型收敛不稳定通常不是模型结构的问题而是缺少梯度裁剪和学习率调度。BERT 冻结之后整体梯度幅度相对可控但注意力层的梯度偶尔会冒尖clip_grad_norm_可以防止单步更新过大把已经学好的参数冲坏。import torch import torch.nn as nn from torch.optim import AdamW from torch.optim.lr_scheduler import CosineAnnealingLR model MultimodalSentimentModel(freeze_bertTrue) criterion nn.CrossEntropyLoss() # 分层学习率MLP 和注意力层用大学习率BERT 用小学习率或不动 optimizer AdamW([ {params: [p for n, p in model.named_parameters() if bert not in n], lr: 1e-3}, {params: model.bert.parameters(), lr: 2e-5}, ], weight_decay1e-2) epochs 30 scheduler CosineAnnealingLR(optimizer, T_maxepochs) best_acc 0.0 bad_epochs 0 patience 5 for epoch in range(epochs): model.train() total_loss 0.0 for batch in train_loader: input_ids batch[input_ids] attention_mask batch[attention_mask] audio_feat batch[audio_feat] video_feat batch[video_feat] labels batch[label] logits, _ model(input_ids, attention_mask, audio_feat, video_feat) loss criterion(logits, labels) loss.backward() nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) optimizer.step() optimizer.zero_grad() total_loss loss.item() scheduler.step() # 验证集评估 早停 model.eval() correct 0 total 0 with torch.no_grad(): for batch in val_loader: logits, _ model( batch[input_ids], batch[attention_mask], batch[audio_feat], batch[video_feat], ) preds logits.argmax(dim1) correct (preds batch[label]).sum().item() total batch[label].size(0) val_acc correct / total print(fEpoch {epoch1}/{epochs}, Loss: {total_loss:.4f}, Val Acc: {val_acc:.4f}) if val_acc best_acc: best_acc val_acc bad_epochs 0 torch.save(model.state_dict(), best_model.pt) else: bad_epochs 1 if bad_epochs patience: print(早停触发停止训练) break这段代码值得注意的细节有几个。分层学习率里 BERT 用2e-5这是预训练模型微调的标准学习率超过1e-4很容易让 BERT 的注意力头学坏而 MLP 投影层和注意力融合层是随机初始化的需要1e-3级别才能快速收敛。CosineAnnealingLR让学习率从初始值平滑降到接近零比固定学习率在后期收敛更稳。早停的patience5意思是连续 5 个 epoch 验证集准确率不涨就停防止无效训练浪费时间。4.3 六个必调参数推荐值与调参方向多模态模型能调的参数很多但真正起决定性作用的就下面这六个。把这些参数按照表格里的方向调基本能让模型从「不收敛」走到「能看」。参数推荐值影响调参方向BERT 学习率2e-5过大会破坏预训练语义冻结 BERT 时无需调MLP 学习率1e-3过小收敛慢过大 loss 震荡不收敛时降到 3e-4batch size16 或 32影响显存和 BN 统计量显存不足降到 8 加梯度累积dropout0.2过拟合时加大验证集比训练集低 5 个点以上加到 0.4冻结层数全冻结显存和效果权衡数据量大时解冻后 4 层 BERT视频帧数16帧越多计算量越大视频动作多时不降否则可减到 8这六个参数里最容易翻车的是 MLP 学习率。很多人会把所有参数统一设成2e-5结果 MLP 投影层学得极慢loss 几乎不动。反过来如果统一设成1e-3BERT 直接被冲坏。分层学习率是这套架构里最关键的调参动作没有之一。4.4 把 notebook 转成脚本跑完整训练Jupyter 里调完了模型最后跑完整训练和交付时建议用nbconvert把 notebook 转成 Python 脚本再用命令行执行。原因有两个一个是 notebook 的 cell 状态容易残留跑完一遍再跑第二遍可能有缓存变量干扰另一个是脚本可以配合nohup或后台任务跑长训练不用一直开着浏览器页面。# 把 notebook 转成可执行脚本并清理输出 jupyter nbconvert --to script train.ipynb python train.py转换出来的脚本会保留所有 cell 的代码顺序但需要手动检查一遍因为print输出和可视化相关的 cell 在脚本里没有意义确认不影响逻辑就行。训练脚本里要固定随机种子否则每次跑的结果都不一样复现性这个点期末答辩时老师大概率会问。import random import numpy as np import torch def set_seed(seed42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) if torch.cuda.is_available(): torch.cuda.manual_seed_all(seed) set_seed(42)5. 五个高发翻车现场多模态情感分析的避坑指南多模态项目的大部分时间不是花在模型设计上而是花在排错上。这一章写的是我自己和相关从业者反复踩过的五个坑按「现象 → 原因 → 解决」展开。每一条都对应一个真实场景照着排查能省下好几个通宵。5.1 Loss 变成 NaN特征里有脏数据不是学习率背锅现象训练到某个 epoch 后 loss 突然变成nan之后再也回不来。重启训练换个小学习率能撑得久一点但迟早还会炸。原因九成情况是输入特征里混进了nan或inf。音频特征提取时遇到损坏的音频文件log_mel里会出现inf视频特征提取时读帧失败返回了空数组。这些坏数据顺着前向传播一路传到 loss反向传播时梯度直接变成nan。很多人的第一反应是调学习率其实方向错了。解决在训练循环开始前对预处理后的特征做一次全局检查。用np.isfinite过滤掉所有含非有限值的样本同时打印被过滤的样本 ID回头检查原始文件是否损坏。顶部clip_grad_norm_要继续留着它防的是梯度爆炸和过滤脏数据是两件事。import numpy as np def check_feature(feat_dict): for key, value in feat_dict.items(): arr np.asarray(value) if not np.isfinite(arr).all(): return False return True # 构造 dataset 时过滤坏样本 valid_samples [s for s in all_samples if check_feature(s)]5.2 融合后效果反而不如单模态特征尺度不一致现象单独用文本特征训练准确率 70%单独用视频特征 65%两个拼起来之后反而掉到 60%。看起来像是融合策略有问题纠结要不要换更复杂的融合模块。原因不是融合模块的问题是特征尺度没对齐。前面提到过BERT 输出经过投影后数值范围可能落在 -1 到 1而音频的 128 维特征如果不做归一化某些维度可能到几百的量级。模型融合时注意力机制会被数值大的模态主导等于只用了一个模态其他模态的信息白白浪费。解决音频和视频投影层的LayerNorm千万别省。如果已经加了还是出现这个问题在stack之前打印三个模态特征的均值方差看一遍哪个模态的方差明显偏大就单独给它加一层 BN 或再调一次LayerNorm。这个检查一分钟就能做完比怀疑模型结构省时间得多。5.3 显存不够导致 OOM冻结 BERT 与梯度累积的组合拳现象训练刚开始batch 还没跑完一个就报CUDA out of memory特别是在视频帧数多、batch size 取 32 的时候。原因视频特征虽然已经离线提好了但模型前向传播时三个模态的中间变量都同时驻留在显存里。BERT 本身就很吃显存融合层的注意力计算还要额外分配空间几项叠加直接爆掉。低显存显卡上这个问题尤其突出。解决第一选择是保证freeze_bertTrue冻结的 BERT 在前向传播时不保存中间激活值显存占用能降一半以上。还不够就把 batch size 降到 8然后用梯度累积模拟更大的 batch。梯度累积的代码在训练循环里改两行就行。accumulation_steps 4 # 相当于 batch size 翻 4 倍 for i, batch in enumerate(train_loader): logits, _ model(...) loss criterion(logits, labels) / accumulation_steps # 归一化 loss.backward() if (i 1) % accumulation_steps 0: nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) optimizer.step() optimizer.zero_grad()注意loss要除以accumulation_steps否则累加的梯度等效学习率会变大导致收敛震荡。如果 8 帧视频 8 的 batch size 还是爆那就只能减小num_frames到 8这会让视频特征的信息量打折但总比训练不起来强。5.4 训练集 99% 而验证集 55%模态对齐出了问题现象训练集准确率一路冲到 99%验证集却始终卡在 55% 附近模型明显过拟合但奇怪的是单模态训练没有这么严重的过拟合。检查了 dropout 和权重衰减都没有明显改善。原因过拟合的表象下真正的问题是模态之间没对齐。具体来说文本、音频、视频三个特征来自同一个样本但在预处理时没有保证它们在时间上或样本顺序上严格同步。比如音频取的是视频前 2 秒文本却是整段字幕模型在训练时学到了「文本和音频本来是两条样本的」这种错误映射验证时原形毕露。解决回到预处理阶段检查三个模态的特征是否来自同一条样本、同一个时间段。常见做法是把每一条样本存成一个 dictkey 是样本 ID文本、音频、视频特征都挂在同一个 key 下面训练 loader 按照 key 对齐读取不要分别存储再按索引拼接。另外在训练前随机抽 10 条样本把三个模态的特征打印出来人工核对一遍这一步虽然土但是最有效。5.5 Jupyter kernel 突然被杀一次性加载全部特征现象预处理完所有视频特征后notebook 运行得好好的训练到第二个 epoch kernel 突然死掉重启后依然是训练到一半就崩。原因Jupyter notebook 把变量都保存在内存里。视频特征如果一次性全部提取并存在一个大的 list 里几十 G 内存直接占满。kernel 被杀不是模型的问题是操作系统把内存耗尽的 Jupyter 进程干掉了。解决不要在 notebook 里保留全量特征。把预处理好的特征落盘成独立的.npy文件或pkl文件然后在torch.utils.data.Dataset的__getitem__里按需读取。这样每个 batch 只临时加载一批特征进内存训练完释放内存占用始终平稳。数据集类代码如下。import torch from torch.utils.data import Dataset import numpy as np class MultimodalDataset(Dataset): def __init__(self, samples): # samples 是 list每一项包含特征文件路径和标签 self.samples samples def __len__(self): return len(self.samples) def __getitem__(self, idx): s self.samples[idx] return { input_ids: np.load(s[text_path])[input_ids], attention_mask: np.load(s[text_path])[attention_mask], audio_feat: np.load(s[audio_path]), video_feat: np.load(s[video_path]), label: s[label], }6. 收尾三件套导出权重、Gradio 演示与报告对比表训练收敛之后离交付还差最后三步把模型权重存好、做一个能跑的演示界面、把实验数据整理成报告里的对比表。这三件事决定期末作业的完成度比模型多调两个点更能反映工程能力。模型保存用state_dict就够了不要整个torch.save(model)前者只存参数换环境也能加载后者会把模型结构一起序列化容易出现版本不兼容。加载的时候先初始化模型再load_state_dict注意模型类的参数要完全一致。import torch # 保存 torch.save(model.state_dict(), multimodal_sentiment.pt) # 加载 model MultimodalSentimentModel(freeze_bertTrue) model.load_state_dict(torch.load(multimodal_sentiment.pt, map_locationcpu)) model.eval()演示界面用 Gradio 能在一小时内搞定。输入是文本、音频文件、视频文件三个框输出是情感判断结果。这里把第 2 章的三个特征提取函数原样复用串起来写一个predict函数就行也是检验整条链路是否真正打通的最好方式。如果 demo 能跑通说明代码交付没有藏坑。import gradio as gr import torch def predict(text, audio_path, video_path): text_enc tokenizer(text, max_length64, paddingmax_length, truncationTrue, return_tensorspt) audio_feat torch.from_numpy(extract_audio_feature(audio_path)).unsqueeze(0) video_feat torch.from_numpy(extract_video_feature(video_path)).unsqueeze(0) with torch.no_grad(): logits, attn_weight model( text_enc[input_ids], text_enc[attention_mask], audio_feat, video_feat, ) pred logits.argmax(dim1).item() return 正向 if pred 1 else 负向 gr.Interface( fnpredict, inputs[gr.Textbox(label字幕), gr.Audio(label音频), gr.Video(label视频)], outputsgr.Textbox(label情感倾向), ).launch()报告里的实验对比是答辩时最直观的得分点。最少放三张表单模态基线对比表文本 only、音频 only、视频 only vs 三模态融合融合方式对比表concat vs 注意力加权以及超参数设置表。每张表配两句话解释重点说「为什么融合比单模态好」和「注意力权重偏向哪个模态」这是多模态项目的核心讨论点写清楚比堆模型结构图有用得多。做多模态情感分析这一年多下来我最深的一个体会是这个项目的难点从来不在模型结构而在数据对齐和特征尺度这种「脏活」上。第 5 章里五个坑我基本全踩过最亏的一次是 5.2 的那个尺度问题当时不信邪换了三种融合结构都没用最后发现只是少了个归一化层。希望这篇笔记能帮你在期末大作业的路上少熬几个夜祝顺利。本文还有配套的精品资源点击获取