简介:这份源码资源面向具备Python与PyTorch基础、希望上手多模态情感分析的开发者与学习者,围绕文本与图像配对数据的三分类任务(积极、中性、消极)给出完整实现方案。项目以BERT提取文本特征,配合轻量图像神经网络完成特征融合,覆盖数据预处理、训练、验证、测试及预测结果保存等环节,并通过命令行参数灵活配置运行流程,适合课程设计、毕业项目或算法入门练手。压缩包共22个文件,约325KB,以7个py源码文件为核心,辅以txt数据说明、json配置与样本、jpg示例图片及requirements.txt依赖清单,目录结构清晰,便于按模块阅读与二次修改。目前已有87人学习下载。读者可据此掌握多模态特征拼接与联合建模思路,理解文本分支与图像分支的代码组织方式,并借助现成脚本快速复现训练与预测流程,为后续调参与模型改进提供可运行基线。
1. 多模态情感分析系统:为什么单靠文本模型总在反讽句上翻车
做情感分析的同学大概率踩过这个坑:一条评论写着“这服务真是绝了,等了两小时”,纯文本模型给出 0.92 的正面置信度,而人一眼就知道这是骂人。文本里的反讽、省略、语气词,单模态模型基本靠猜。图像和语音里其实藏着大量互补信号——表情、语调、配图情绪,这些恰好能补上文本的盲区。基于 PyTorch 框架的多模态情感分析系统,做的就是把这几个模态对齐、融合、再分类这件事。它适合两类人:一类是想把情感分析从“跑通 demo”推进到“能上业务”的算法工程师,另一类是手里有图文/视频评论数据、想找个可复现工程骨架的开发者。这篇笔记按“数据怎么对齐 → 模型怎么搭 → 训练怎么调 → 坑在哪”的顺序拆,源码结构、张量维度、损失函数这些都会落到具体数字上,你照着改就能跑自己的数据。
2. 多模态情感分析系统的数据管线:从原始图文到对齐张量
多模态系统翻车,八成不是模型的问题,是数据管线没对齐。文本长度、图像尺寸、模态缺失这三件事,任何一个处理不干净,训练时 loss 就会震荡或者直接 NaN。这一章把数据从磁盘到 DataLoader 的完整链路讲清楚。
2.1 三个模态的预处理与张量形状约定
先定死形状约定,后面所有代码都围绕它写。文本走 tokenizer 后统一到max_len=128,图像 resize 到224x224再归一化,如果带语音就统一到 16kHz 单声道、截断/补齐到 5 秒。形状约定如下表,建议直接抄进你的 config:
| 模态 | 原始输入 | 处理后张量 | 说明 |
|---|---|---|---|
| 文本 | 评论文本 | [B, 128]int64 | padding 到 128,attention_mask 同步生成 |
| 图像 | jpg/png | [B, 3, 224, 224]float32 | ImageNet 均值方差归一化 |
| 语音 | wav | [B, 1, 80000]float32 | 16kHz × 5s,梅尔谱在模型内算 |
关键点是模态缺失。真实数据里经常只有文本没有图,硬塞零张量会让模型学到“全零=负面”这种伪相关。常见做法是加一个modality_mask,形状[B, 3],哪个模态存在就置 1,融合时用它做加权。这一步不做,后面融合层再花哨都是白搭。
2.2 用 Dataset 和 collate_fn 把多模态样本拼成 batch
PyTorch 的默认collate_fn处理不了“有的样本有图有的没有”这种变长结构,必须自己写。下面是一个可直接复用的骨架:
import torch from torch.utils.data import Dataset, DataLoader from PIL import Image import torchvision.transforms as T class MultimodalDataset(Dataset): def __init__(self, records, tokenizer, max_len=128): self.records = records # list of dict: text, image_path, label self.tokenizer = tokenizer self.max_len = max_len self.img_tf = T.Compose([ T.Resize((224, 224)), T.ToTensor(), T.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ]) def __len__(self): return len(self.records) def __getitem__(self, idx): r = self.records[idx] enc = self.tokenizer( r["text"], truncation=True, padding="max_length", max_length=self.max_len, return_tensors="pt") item = { "input_ids": enc["input_ids"].squeeze(0), "attention_mask": enc["attention_mask"].squeeze(0), "label": torch.tensor(r["label"], dtype=torch.long), "has_image": torch.tensor(0 if r.get("image_path") is None else 1), } if r.get("image_path"): item["image"] = self.img_tf(Image.open(r["image_path"]).convert("RGB")) else: item["image"] = torch.zeros(3, 224, 224) # 占位,靠 has_image 屏蔽 return item def collate_fn(batch): out = {} for key in ["input_ids", "attention_mask", "image", "label", "has_image"]: out[key] = torch.stack([b[key] for b in batch]) return out逻辑说明:__getitem__里对缺失图像返回全零占位,同时用has_image标记真实存在性,这样 batch 内形状统一,模型侧再用 mask 屏蔽。参数上max_len=128是中文评论的常用值,长评论多的话调到 256,但注意显存会线性涨。collate_fn只做 stack,不做 padding,因为前面已经 pad 到定长,这样最省心。
提示:如果你的图像分辨率不统一且不想 resize,别用默认 collate,改成按 batch 内最大尺寸 pad,但那样 BN 层容易出问题,新手不建议。
2.3 数据划分与类别不平衡的处理顺序
划分要在预处理之前做,否则归一化统计量会泄漏测试集信息。顺序是:先按 8:1:1 切 train/val/test,再各自统计。情感分析数据集普遍正负不均,常见做法是训练集用加权采样WeightedRandomSampler,权重取类别频率的倒数。别一上来就上 focal loss,先把采样调平,很多时候就够了。验证集和测试集保持原始分布,不要重采样,否则指标虚高,上线就露馅。
3. 融合网络怎么搭:从单模态编码到跨模态注意力的取舍
模型结构是这套系统里最容易被过度设计的地方。我见过有人一上来就堆三层 cross-attention,结果参数量翻倍、指标没动。这一章讲清楚编码器选型、融合方式对比,以及一个能跑通的融合模块怎么写。
3.1 文本、图像编码器的选型与冻结策略
文本编码器首选预训练模型,中文场景用 BERT-base-chinese 或 RoBERTa,输出[B, 128, 768]。图像编码器用 ResNet-50 或 ViT-Base,ResNet 取全局池化后的[B, 2048],ViT 取 cls token 的[B, 768]。选型原则:数据量小于 1 万条时,两个编码器都冻结,只训融合层和分类头;数据量上万再解冻顶层做微调。全量微调在小数据集上必过拟合,这是血泪经验。
冻结的实现很简单,遍历参数把requires_grad置 False,但要注意 BN 层即使冻结也会更新 running stats,稳妥做法是冻结时把模型设成eval()模式,训练融合层时再单独控制。
3.2 早期融合、晚期融合与跨模态注意力的对比
三种融合方式的取舍直接决定你的工程复杂度:
| 融合方式 | 做法 | 参数量 | 适用场景 |
|---|---|---|---|
| 早期融合 | 特征 concat 后接 MLP | 小 | 模态对齐好、数据少 |
| 晚期融合 | 各模态单独分类再投票/加权 | 最小 | 模态缺失严重 |
| 跨模态注意力 | 用文本 query 图像 | 大 | 数据充足、追求指标 |
我的建议:先用晚期融合跑通 baseline,再上跨模态注意力。晚期融合虽然简单,但在模态缺失场景下最鲁棒,因为每个模态的分支独立,缺一个不影响另一个。跨模态注意力在数据量不足时,注意力权重学不出来,反而比 concat 还差。
3.3 一个可复用的跨模态注意力融合模块
下面这个模块用文本做 query、图像做 key/value,输出融合后的文本表示,再送分类头。维度都标好了,直接改d_model就能用:
import torch import torch.nn as nn class CrossModalFusion(nn.Module): def __init__(self, d_text=768, d_image=2048, d_model=512, n_head=8): super().__init__() self.proj_text = nn.Linear(d_text, d_model) self.proj_image = nn.Linear(d_image, d_model) self.attn = nn.MultiheadAttention(d_model, n_head, batch_first=True) self.norm1 = nn.LayerNorm(d_model) self.ffn = nn.Sequential( nn.Linear(d_model, d_model * 4), nn.GELU(), nn.Linear(d_model * 4, d_model)) self.norm2 = nn.LayerNorm(d_model) self.drop = nn.Dropout(0.1) def forward(self, text_feat, image_feat, image_mask=None): # text_feat: [B, L, d_text] image_feat: [B, d_image] q = self.proj_text(text_feat) # [B, L, d_model] kv = self.proj_image(image_feat).unsqueeze(1) # [B, 1, d_model] if image_mask is not None: # image_mask: [B] 0/1,0 表示缺失,屏蔽掉 kv = kv * image_mask.view(-1, 1, 1) attn_out, _ = self.attn(q, kv, kv) # [B, L, d_model] x = self.norm1(q + self.drop(attn_out)) x = self.norm2(x + self.drop(self.ffn(x))) return x.mean(dim=1) # [B, d_model]逻辑说明:文本序列做 query,图像特征扩展成一个 token 做 key/value,注意力让每个文本位置去“看”图像信息。image_mask是关键,缺失图像的样本这里置 0,注意力输出就退化成对零向量的加权,配合后续 LayerNorm 不会污染表示。参数上d_model=512是显存和表达的平衡点,n_head=8对应每个头 64 维,是标准配置。dropout=0.1在小数据集上可以加到 0.3。
注意:
nn.MultiheadAttention的batch_first=True在 PyTorch 1.9 之后才稳定,老版本要手动 transpose,别踩这个版本坑。
4. 训练与调参:让多模态情感分析系统真正收敛的实操细节
模型搭好只是开始,多模态训练的调参空间比单模态大得多。这一章讲损失函数、学习率策略和几个必调参数,都是能直接影响收敛的。
4.1 损失函数选择与标签平滑
标准做法是交叉熵,但情感分析标签常有噪声(标注歧义),加标签平滑能明显稳住验证集。nn.CrossEntropyLoss(label_smoothing=0.1)一行搞定。如果类别极不平衡且采样后仍不够,再考虑 focal loss,但 focal 的gamma和alpha两个超参很难调,我一般先用平滑交叉熵,指标不够再换。
多模态还有个特殊点:可以加模态一致性损失,让两个模态的预测分布接近,公式是 KL 散度。但这不是必选项,数据量小的时候加了反而拖慢收敛,建议作为消融实验的备选。
4.2 分层学习率与 warmup 的设置
编码器是预训练的,融合层是随机初始化的,两者用同一个学习率必然出问题——融合层还没学好,编码器已经被带偏了。正确做法是分层学习率:
def build_optimizer(model, encoder_lr=2e-5, head_lr=1e-3, weight_decay=0.01): encoder_params, head_params = [], [] for name, param in model.named_parameters(): if not param.requires_grad: continue if "encoder" in name: encoder_params.append(param) else: head_params.append(param) return torch.optim.AdamW([ {"params": encoder_params, "lr": encoder_lr}, {"params": head_params, "lr": head_lr}, ], weight_decay=weight_decay)逻辑说明:编码器学习率设 2e-5(BERT 微调的经典值),融合层和分类头设 1e-3,差两个数量级。weight_decay=0.01是 AdamW 的常用值。warmup 用get_linear_schedule_with_warmup,warmup 比例取总步数的 10%,能避免训练初期 loss 爆炸。
4.3 三个必调参数与早停策略
按重要性排序,这三个参数最值得花时间:
- batch size:多模态显存吃紧,一般 16 或 32。太小 BN 统计不稳,太大收敛慢。显存够就 32。
- 融合层 dropout:0.1 到 0.5 之间调,小数据集往大调。这个参数对过拟合的影响比学习率还大。
- max_len:128 起步,长文本数据集调到 256,但注意注意力是 O(n²),显存翻倍。
早停用验证集 F1,patience 设 3 到 5 个 epoch。多模态模型验证指标波动比单模态大,patience 太小会误停,我一般设 5。
5. 避坑与排查:多模态情感分析系统最常见的五类翻车
这一章全是踩过的坑,按“现象 → 原因 → 解决”写,遇到对应症状直接对号入座。
坑一:loss 一直不降,卡在 0.69 附近。现象是训练几个 epoch,loss 稳定在 ln(2)≈0.693 不动。原因是融合层输出被某个模态主导,或者 mask 写反了导致有效信息被屏蔽。解决:先单独跑文本分支,确认单模态能收敛;再检查modality_mask的 0/1 语义,缺失应该是 0 而不是 1。打印融合层输出的均值和方差,如果接近 0 说明被 mask 吃掉了。
坑二:验证集指标远低于训练集,差距 20 个点以上。现象是训练 F1 0.95,验证 0.72。原因是过拟合,多模态参数量大尤其明显。解决:先冻结编码器只训融合层,如果差距缩小说明是编码器过拟合;再加 dropout 到 0.3~0.5;最后考虑数据增强,图像用随机裁剪翻转,文本用同义词替换。别急着加数据,先把正则调到位。
坑三:模态缺失样本预测全是同一类。现象是只有文本的样本,模型全预测成中性或负面。原因是训练时缺失样本的占位零张量被模型当成了有效特征。解决:确认has_image参与了 mask 计算,且缺失样本的融合输出不依赖图像分支。可以在训练时随机 drop 掉 10%~20% 的图像,做模态 dropout 增强,让模型学会在缺失下工作。
坑四:显存溢出,batch size 降到 4 还 OOM。现象是CUDA out of memory。原因是图像编码器 + 文本编码器 + 注意力的激活值叠加。解决:图像编码器冻结并设eval(),用torch.no_grad()包住图像前向;开启混合精度torch.cuda.amp;梯度累积模拟大 batch。这三招下来显存能省一半以上。
坑五:多卡训练指标反而下降。现象是单卡 F1 0.85,DataParallel 双卡变 0.80。原因是 BN 层在多卡下统计量不同步,或者 batch 被切分后每卡样本太少。解决:换DistributedDataParallel,或者干脆单卡跑;如果必须多卡,把 BN 换成SyncBatchNorm。小数据集上多卡收益本来就有限,别硬上。
6. 从跑通到上线:多模态情感分析系统的验证与导出技巧
模型训完,怎么确认它真的能用,而不是在验证集上“碰巧”好看?我一般做三件事。第一,构造对抗样本:把明显反讽的句子、图文矛盾的样本单独拎出来测,看模型是否被单模态带偏。第二,分模态消融:分别只给文本、只给图像、都给,对比指标,确认多模态确实有增益,而不是文本一枝独秀。第三,看混淆矩阵的边界类:中性类最容易和正负混淆,如果中性 F1 明显低,说明融合层没学好细粒度区分。
导出上线时,PyTorch 转 ONNX 是常见路径,但多模态模型有坑。动态长度输入要在torch.onnx.export里用dynamic_axes声明,文本的input_ids和attention_mask都要标。图像分支如果用了自适应池化,ONNX 对某些算子支持不全,导出后务必用onnxruntime跑一遍数值对齐,误差超过 1e-3 就说明有算子没对上。下面是一个导出骨架:
import torch model.eval() dummy_text = torch.randint(0, 30000, (1, 128)) dummy_mask = torch.ones(1, 128, dtype=torch.long) dummy_img = torch.randn(1, 3, 224, 224) dummy_has = torch.ones(1, dtype=torch.long) torch.onnx.export( model, (dummy_text, dummy_mask, dummy_img, dummy_has), "multimodal_sentiment.onnx", input_names=["input_ids", "attention_mask", "image", "has_image"], output_names=["logits"], dynamic_axes={ "input_ids": {0: "batch", 1: "seq"}, "attention_mask": {0: "batch", 1: "seq"}, "image": {0: "batch"}, "has_image": {0: "batch"}, "logits": {0: "batch"}, }, opset_version=14, )逻辑说明:dynamic_axes把 batch 和序列长度都标成动态,这样上线时变长输入不用重导。opset_version=14对 MultiheadAttention 支持较好,低于 12 会报算子不支持。导出后一定用同一组输入对比 PyTorch 和 onnxruntime 的输出,np.allclose误差设 1e-3。
最后说个习惯:我每次改完融合结构,都会先在一个 500 条的小子集上过拟合,确认模型有能力把训练集打到接近 100%。如果连小子集都过拟合不了,说明结构或 mask 有 bug,别急着上全量数据。这个“先过拟合小样本”的习惯帮我省了无数次通宵排查。希望帮到你。
本文还有配套的精品资源,点击获取