ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

多模态情感分析实战:BERT+ResNet融合与部署指南

多模态情感分析实战:BERT+ResNet融合与部署指南 简介面向计算机相关专业毕设学生与项目实战学习者基于BERTResNet的多模态情感分析完整项目提供源码与配套文档说明。项目实现多种特征融合方法包括朴素拼接、跨模态注意力、基于Transformer编码器的不同融合策略适配文本与图像联合情感分析任务。压缩包共39个文件以17个Python源码为主覆盖数据预处理、模型构建、训练评估等流程另含3个模型结构示意图、3个文本说明文档以及训练与测试数据文件整体大小约445KB便于快速复现实验。目前已吸引140人学习浏览代码完整可运行适合用作毕业设计、课程设计或期末大作业的参考范本。读者借助文档与README可理清各融合模型的差异与实现思路直接基于现有数据完成训练和效果对比。1. 多模态情感分析为什么绕不开 BERTResNet先从一条带图的评论说起一条外卖评论写着“味道不错下次还会点”配图却是咬了一口就丢下的汉堡另一条说“再也不来了”照片里却是舔得干干净净的空盘。只看文本或只看图片结论都会反。这就是多模态情感分析要解决的——让 BERT 处理文本语义、ResNet 提取图像特征再把两路信息按多种方式融合输出一个综合情绪判断。它不是新概念但 BERTResNet 是目前源码最全、最容易复现的组合特别适合电商评价、舆情监控这类图文并存的场景。适合手里已有 Python 基础、想直接跑通一个多模态项目的读者。下面从选型讲到部署坑位一次踩完。2. 融合方式怎么选early、late 与注意力融合的取舍2.1 BERT 做文本侧的底线为什么 word2vec 在这里不够用情感分析吃的是上下文不是词频。“不错”和“不是很不错”只差一个字word2vec 把词向量一平均这两句话几乎变成同一个向量分类器根本分不开。BERT 是双向 Transformer能建模否定词、转折词和语气词之间的依赖对短文本特别友好而评论这种 UGC 文本恰恰又短又碎、满口语。所以文本侧选 BERT 不是跟风是语义边界上的底线。实操上我用 bert-base-chinese序列长度固定在 128。情感极性是句子级判断不需要长文档的 512 窗口窗口开太长位置编码会稀释 [CLS] 的语义训练也慢。特征取最后一层 [CLS] 位置的输出维度 768。下面是常见的特征提取代码from transformers import BertTokenizer, BertModel import torch tokenizer BertTokenizer.from_pretrained(bert-base-chinese) bert BertModel.from_pretrained(bert-base-chinese) def encode_text(text): inputs tokenizer( text, max_length128, truncationTrue, paddingmax_length, return_tensorspt, ) with torch.no_grad(): out bert(**inputs) return out.last_hidden_state[:, 0, :] # [CLS] 向量shape: [1, 768]参数说明max_length128同时兼作 padding 与 truncation 的上限超过部分直接截断return_tensorspt返回 PyTorch Tensor省去手动转换。with torch.no_grad()只在冻结提取阶段用后面微调时必须去掉否则梯度传不回 BERT。我一般会把冻结提取的特征存成.npy文件用于单模态基线实验这样能先知道文本侧的上限在哪。[CLS] 这个位置在 BERT 预训练时就被设计为汇总整句语义后面接分类头几乎是约定俗成。多模态模型里它直接作为融合的 query 或拼接源768 维的细粒度文本特征和 2048 维的图像特征形成互补融合模块要处理的正是这种尺度差异。如果你做的是英文数据把bert-base-chinese换成bert-base-uncased即可其余代码不用动。2.2 ResNet 做图像侧的底线预训练权重在 torchvision 里直接取图像侧处理的是表情、场景和物体状态——外卖照片里剩了多少饭、买家秀里滤镜重不重、评论配图有没有糊成一团。ResNet50 在这是卡位最稳的选择残差结构解决了网络加深以后的退化问题50 层在 ImageNet 上预训练好的权重torchvision 一行就能下载迁移成本接近零。相比 VGG 体积更小、比 EfficientNet 的预训练权重来源更清晰这是它被大多数多模态源码包默认选中的原因。关键动作是砍掉最后的全连接分类头保留全局平均池化之后的 2048 维特征向量。写法上有两种一种是把model.fc换成nn.Identity()另一种是直接取model.avgpool之后的输出。前者更稳因为你不需要手工对齐层名。import torch.nn as nn from torchvision.models import resnet50, ResNet50_Weights from torchvision import transforms resnet resnet50(weightsResNet50_Weights.IMAGENET1K_V1) resnet.fc nn.Identity() # 去掉分类头forward 输出变为 [B, 2048] image_transform transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]), ])这里有个血泪教训不少新手会把Normalize漏掉或者只做了Resize没按 ImageNet 统计值归一化导致输入分布和预训练权重完全不匹配图像分支的 loss 掉不下去。mean/std是 ImageNet 的统计值跟你的数据集无关不需要重新统计。Resize到 224x224 会拉伸非正方形图片但 CNN 对长宽比畸变不敏感比裁剪丢失信息少。关于是否冻结 ResNet我的习惯是第一轮先冻结只训练融合层和分类头等文本侧进入平稳期再解冻 ResNet以 1e-4 的初始学习率微调。解冻时 BatchNorm 也跟着解冻但不要给太高的学习率。ResNet 解冻后第二个 epoch loss 突然拉高是常见现象这不是模型坏了是 BN 的统计量在剧烈变化调低学习率重训一轮就会回落。2.3 三种融合方式的适用场景单选、加权与注意力特征拿到手接下来才是这个标题真正的核心怎么融合。常见做法有三条路。早期融合最简单把 768 维文本向量和 2048 维图像向量拼接成 2816 维接一层 BN 加全连接再进分类头。实现几乎没有成本但两个模态的分布差异太大、量纲不一致模型要自己学一个很大的权重矩阵去调和样本量不够时容易过拟合。晚期融合同样简单文本和图像各训练一个独立的分类头得到两个情感概率分布最后加权平均。权重可以固定比如 0.6/0.4也可以放到验证集上搜索。它的好处是有一个模态缺失时系统仍然能跑坏处是学不到模态之间的交互——图里明显在笑、文字却在吐槽这种矛盾样本晚期融合只会给出一个和稀泥的中间结果。中间融合也就是注意力融合是我在源码里默认推荐的思路把文本特征作为 query图像特征作为 key/value做一次 cross-attention反过来再做一次 image-text 的注意力再把两路输出拼起来。这样模型每次预测时都能量化“这次判断主要靠文字还是靠图”相当于给决策加了一个可解释的开关。伪代码如下# text_feat: [B, 768], image_feat: [B, 2048]先映射到同一维度 d512 text_proj nn.Linear(768, d) image_proj nn.Linear(2048, d) Q text_proj(text_feat).unsqueeze(1) # [B, 1, d] K image_proj(image_feat).unsqueeze(1) # [B, 1, d] attn torch.softmax(torch.bmm(Q, K.transpose(1, 2)) / (d ** 0.5), dim-1) fused attn * K Q # 注意力加权后的图像信息叠加到文本上注意力权重本身就是一张可读的热力图attn值接近 1 说明图像主导接近 0 说明文本主导。调试时我把这段权重打印出来发现图文矛盾样本里模型大多把权重分到文本侧原因是文本语义密度远高于单张图片。想提升图像侧的地位最有效的办法不是换融合结构而是先把图片里的物体区域切出来用区域特征替代整图特征。3. 把图文数据对齐多模态训练的第一步卡在这里3.1 数据组织从散落的图片和评论到 JSONL多模态项目花时间最多的不是模型是数据对齐。我接手这类项目时拿到的数据通常是两张表一张存评论 ID、文本、图片 URL一张存人工标注标签。要做的是把两张表按 ID 合并把图片下载到本地最后每行写成一条 JSONL 记录。图片路径必须存相对路径避免把训练机上的绝对路径带进配置。project_dir/ ├── data/ │ ├── train.jsonl │ ├── val.jsonl │ └── images/ │ ├── 0001.jpg │ ├── 0002.jpg │ └── ... ├── src/ │ ├── dataset.py │ ├── model.py │ ├── fuse.py │ └── train.py ├── weights/ └── README.mdJSONL 比 CSV 合适是因为每行是一条独立的图文对不会像 CSV 那样被逗号、换行符搞崩。一行一条{text: 环境不错但菜太咸饮料倒是很好喝, image: images/0001.jpg, label: 1}字段就三个谁都能看懂。text里保留了逗号和顿号JSON 序列化会自动转义image只存相对路径不存 URL训练时换机器不用改路径。label是整数。加载时用json.loads(line)逐行读不需要 pandas内存可控、断点续训也方便。注意如果原始数据里一张图配多条评论建议按“一条评论 一张图”拆成独立样本。别把多条评论拼进一条样本因为融合模块对“一条文本对一张图”的假设会失效后面的 attention 计算也会错位。文件命名用0001.jpg这种纯数字最省事不要带特殊字符Windows 和 Linux 都能跑。3.2 标签设计二分类还是五分类边界怎么定情感标签通常三选一二分类正向/负向、三分类负/中/正、五分类-2 到 2。我一般先做三分类因为中性样本在真实数据里占比极高硬分二分类会把很多“还行”逼成错误标签。模型输出三个 logits用 CrossEntropyLoss 就能训改动成本最低。五分类的边界问题最头疼“一般般”算 0 还是 -1“还可以”算 0 还是 1标注一致性很容易掉到 70% 以下。解决办法是定打分锚点跟同价位同类目比不如预期给 -1基本符合给 0超出预期给 1。标注规范里写死这几条机器学习的上限由标注质量决定这步省不了。代码里对应LABEL_MAP {negative: 0, neutral: 1, positive: 2}如果你想输出多个维度比如同时表达“菜品差”和“服务好”那就不是多分类而是多标签分类损失函数要换成BCEWithLogitsLoss而不是CrossEntropyLoss。这个区别源码里很容易写错训练时 loss 没降不要先调学习率先确认损失函数和标签形式是否匹配。3.3 样本划分、增强与目录约定数据泄漏是多模态情感分析里最隐蔽的坑。同一个用户写的评论、拍的图措辞和滤镜风格高度一致如果随机划分模型相当于见过“同类样本”再考试。正确做法是按user_id或item_id分组划分from sklearn.model_selection import GroupShuffleSplit split GroupShuffleSplit(n_splits1, test_size0.2, random_state42) train_idx, val_idx next(split.split(samples, groups[s[user_id] for s in samples]))GroupShuffleSplit按组划分保证同一个用户的所有样本都在同一边验证集结果才可信。random_state固定方便复现和对比实验。这在多模态项目里比单模态严重得多因为图像侧的同手机、同滤镜泄漏信息量远超文本。样本不均衡是电商评价的老问题正向永远是多数。我在训练时给少数类加权weight 样本总数 / (类别数 x 该类别数量)在损失函数里传class_weight比过采样轻量。过采样会成倍复制图片把数据加载 IO 抬高训练速度明显变慢。数据增强上图像侧做随机裁剪和水平翻转就够了文本侧不要做同义词替换“好吃”换成“美味”可能改变情感强度噪声大于收益样本量低于一万时文本侧干脆不做增强。最后是目录约定。weights/下存best_model.pt和last_model.ptREADME 写清数据格式和启动命令。很多人拿到源码包习惯先跑一遍看 loss我反而建议先读 README 的数据格式说明——源码笔记的价值在这里才体现出来别人拿到仓库不用猜就能复现以后自己回看也不会一脸懵。4. 训练主流程BERTResNet 同步微调的完整代码形态4.1 数据加载Dataset 与 tokenizer / transform 的配合多模态 Dataset 和单模态的差别在于一个样本要同时走两套预处理文本走 tokenizer图像走 transform两边返回后要在 batch 里对齐。我习惯把所有逻辑写进一个类import json import torch from torch.utils.data import Dataset from PIL import Image class MultimodalDataset(Dataset): def __init__(self, jsonl_path, tokenizer, image_transform): self.samples [json.loads(line) for line in open(jsonl_path, encodingutf-8)] self.tokenizer tokenizer self.image_transform image_transform def __len__(self): return len(self.samples) def __getitem__(self, idx): s self.samples[idx] text_input self.tokenizer( s[text], max_length128, truncationTrue, paddingmax_length, return_tensorspt, ) image Image.open(s[image]).convert(RGB) image self.image_transform(image) label torch.tensor(s[label], dtypetorch.long) return { input_ids: text_input[input_ids].squeeze(0), attention_mask: text_input[attention_mask].squeeze(0), image: image, label: label, }逻辑说明tokenizer 默认返回[1, seq_len]squeeze(0)后变成[seq_len]DataLoader 自动把 batch 内多个样本拼成[B, seq_len]。图像 transform 已经包含ToTensor和Normalize返回的 shape 是[3, 224, 224]。PIL.Image.open之后必须convert(RGB)灰度图或 RGBA 图不转会在 batch 拼接时报维度错误。参数说明max_length128在 Dataset 层已经做了 padding 和 truncation不需要在 batch 层动态 pad。如果你的文本长度差异极大可以改用 tokenizer 的paddingTrue加自定义 collate_fn 动态 pad但对情感分析这种短文本场景固定 128 更省心复现性也好。图片路径是相对路径我习惯在 Dataset 里拼一个self.data_root前缀否则 Windows 和 Linux 的路径分隔符不一致会踩坑。4.2 融合模型的 forward 写法从特征到 logits融合模型的整体结构是 BERT 和 ResNet 并行各自输出特征再过一个融合层。完整模型类如下import torch import torch.nn as nn class BERTResNetFusion(nn.Module): def __init__(self, bert, resnet, d512, num_classes3): super().__init__() self.bert bert self.resnet resnet self.text_proj nn.Linear(768, d) self.image_proj nn.Linear(2048, d) self.classifier nn.Sequential( nn.Linear(d * 2, 256), nn.ReLU(inplaceTrue), nn.Dropout(0.3), nn.Linear(256, num_classes), ) def forward(self, input_ids, attention_mask, image): text_emb self.bert( input_idsinput_ids, attention_maskattention_mask ).last_hidden_state[:, 0, :] # [B, 768] image_emb self.resnet(image) # [B, 2048] text_h self.text_proj(text_emb) # [B, d] image_h self.image_proj(image_emb) # [B, d] attn_w torch.bmm( text_h.unsqueeze(1), image_h.unsqueeze(2) ) / (self.text_proj.out_features ** 0.5) # [B, 1, 1] attn_w torch.softmax(attn_w, dim-1) image_weighted attn_w * image_h.unsqueeze(1) # [B, 1, d] fused torch.cat([text_h.unsqueeze(1), image_weighted], dim-1).squeeze(1) # [B, 2d] logits self.classifier(fused) return logits逻辑说明text_emb取 [CLS]image_emb是 ResNet 去分类头后的 2048 维特征两者先映射到 512 维公共空间再计算文本对图像的注意力权重。这个注意力本质是一个点积标量表示整条样本情感的图像贡献度用torch.bmm一次算完整个 batch避免了循环。fused是文本向量和图像加权向量的拼接过两层全连接出三分类 logits。参数说明d512是公共维度太小信息丢失、太大会在样本量不足时过拟合。classifier里的Dropout(0.3)是经验默认值后面验证集过拟合时往上调。想要更强的注意力表达可以把标量注意力换成多头Q text_h.unsqueeze(1) K image_h.unsqueeze(1) V image_h.unsqueeze(1) attn torch.bmm(Q, K.transpose(-1, -2)) / (d ** 0.5) Z torch.bmm(torch.softmax(attn, dim-1), V)这两种我都试过单标量注意力在小数据上训练更稳定多头注意力在样本上万时才体现优势。先跑简版指标不够再上复杂版。4.3 训练循环与关键超参学习率、梯度裁剪、混合精度训练循环看起来和单模态没什么不同但参数分隔是重点。BERT 和 ResNet 的学习率必须分开设否则总会有一侧把另一侧带偏。我用AdamW加参数组from torch.optim import AdamW from torch.optim.lr_scheduler import LinearLR optimizer AdamW([ {params: bert.parameters(), lr: 2e-5}, {params: resnet.parameters(), lr: 1e-4}, {params: model.text_proj.parameters(), lr: 1e-4}, {params: model.image_proj.parameters(), lr: 1e-4}, {params: model.classifier.parameters(), lr: 1e-4}, ]) criterion nn.CrossEntropyLoss() scheduler LinearLR(optimizer, start_factor0.1, total_iters2) # 前2个epoch预热 for epoch in range(15): for batch in train_loader: input_ids batch[input_ids].cuda() attention_mask batch[attention_mask].cuda() image batch[image].cuda() labels batch[label].cuda() logits model(input_ids, attention_mask, image) loss criterion(logits, labels) optimizer.zero_grad() loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) optimizer.step() scheduler.step()参数说明2e-5是 BERT 微调的常规值再大会破坏预训练权重出现几个 step 后 loss 猛涨的情况。1e-4给 ResNet因为它只做图像域迁移任务差异比文本侧小。clip_grad_norm_设 1.0 是为了防止梯度爆炸——多模态里两个分支梯度量级不同不裁剪的话图像侧很容易先炸掉。显存紧张时用混合精度把 forward 和 loss 包在 autocast 里with torch.autocast(device_typecuda, dtypetorch.float16): logits model(input_ids, attention_mask, image) loss criterion(logits, labels)AMP 在情感分类这种任务上精度损失很小换来的是 batch size 可以翻倍T4 显卡上 batch 16 也跑得动。注意验证时记得关掉 autocast 并调用model.eval()否则后续导出 ONNX 时会残留 cast 节点推理速度反而变慢。5. 避坑指南多模态训练常见的翻车现场5.1 图像分支学不动冻结与解冻的节奏现象训练到第 10 个 epoch融合模型在验证集上的准确率跟纯文本模型几乎一样把图像输入换成随机噪声预测结果一点不变。这说明图像分支根本没参与决策ResNet 这部分成了黑匣子。原因最常见的有两个。一是 BERT 微调收敛太快文本分支的梯度量级比图像分支大一两个数量级融合层权重被文本侧主导图像侧的更新被冲掉二是 ResNet 一直被冻结没有在任务数据上重新适应2048 维特征和文本特征不在同一个语义空间里。解决用前两个周期的策略——前 2 个 epoch 冻结 ResNet 只训融合层和分类器让分类头先学会使用图像特征第 3 个 epoch 起解冻 ResNet同时把 BERT 学习率降到 1e-5。如果解冻后 loss 反而升高不要立刻回退调低学习率重训一次通常在第二个解冻 epoch 回落。顺便把text_feat和image_feat用 PCA 降到二维画个散点图两个模态在空间里是揉成一坨还是明显分簇一眼就能判断融合结构还有没有救。5.2 文本侧过拟合与数据泄漏验证集的划分玄学文本过拟合的表现很典型训练集准确率 0.95验证集只有 0.81val loss 在第 8 个 epoch 开始反弹。原因是情感分类文本短、词汇少BERT 记性太好两三个 epoch 就能把训练文本背下来。解决方法是把 BERT 学习率从 2e-5 降到 1e-5同时把 Dropout 从 0.3 提到 0.5还压不住就加 weight decay 0.01。数据量超过五万时过拟合不明显一万以内的项目基本必踩。数据泄漏是另一个隐蔽问题。现象随机划分时验证集准确率 0.93换成按用户分组划分后掉到 0.87说明高出来的几个点是用户风格泄漏带来的假象。原因同一个用户拍的图、写的评论光影、措辞高度一致随机划分等于让模型先见过“同类样本”再考试。解决按user_id用GroupShuffleSplit划分代码在第 3 章。多模态项目里这条比单模态严重得多图像侧同一个手机、同一个滤镜泄漏信息量远超文本别贪那两三个点的假指标。5.3 融合后指标反而倒退先查单模态基线与模态缺失融合倒退是这类项目里最容易让人心态崩的现象。文本单模态 0.88图像单模态 0.79一融合变成 0.84比文本还低两三个点。原因并不玄融合层试图同时拟合两路分布而图像特征噪声大把文本已经答对的样本也带偏了。解决先分别跑文本、图像单模态基线确认图像侧上限确实存在。如果图像基线就上不去先解决图像特征质量问题比如改用区域特征或换更强的预训练权重而不是急着调融合结构。在小数据上固定 0.7/0.3 的加权平均比 full attention 更稳注意力模块在这里不一定能学到有用交互。模态缺失是上线之后才会暴露的坑。现象单测正常线上准确率大跌一查是不少样本根本没有配图或文字为空。原因实际业务里图片会因上传中断、存量数据缺失而为空多模态模型没见过缺失输入把空图硬塞进 ResNet 会输出一堆垃圾特征。解决训练时以 10% 的概率把图像分支置零或把attention_mask全置 0让模型学会一个模态缺失时靠另一个兜底推理侧检测到缺失时直接走单模态分支不要硬走融合通道。from sklearn.metrics import classification_report print(classification_report(y_true, y_pred, target_names[neg, neu, pos]))每轮实验后都打印分类报告别只盯着平均准确率。多模态模型经常出现“整体没变、但某个类别被另一模态带崩”的情况按类别看 F1 才能定位是哪个分支在拖后腿。6. 端到端验证把融合模型导出 ONNX 和 PyTorch 对拍6.1 导出与对拍交付前最后一道坎训练完不要只看验证集指标。PyTorch 模型依赖 Python 环境和 torch 版本交付给线上服务时通常转 ONNX。导出有几个固定动作model.eval()、准备 dummy 输入、指定动态 batch 轴。import torch.onnx model.eval() dummy_text torch.randint(0, 20000, (1, 128), dtypetorch.long) dummy_mask torch.ones(1, 128, dtypetorch.long) dummy_image torch.randn(1, 3, 224, 224) torch.onnx.export( model, (dummy_text, dummy_mask, dummy_image), multimodal_sentiment.onnx, input_names[input_ids, attention_mask, image], output_names[logits], dynamic_axes{ input_ids: {0: batch}, attention_mask: {0: batch}, image: {0: batch}, }, opset_version17, )逻辑说明dynamic_axes声明 batch 维可变线上推理时一次传多长都行。如果漏掉这个参数导出的模型固定 batch1服务端并发一多就得重新导出。dummy 输入只走一次前向不参与训练所以randn生成随机图没问题。导出后立刻回读数据对拍拿同一个 batch分别跑 PyTorch 和 onnxruntime逐元素比较输出。import onnxruntime as ort sess ort.InferenceSession(multimodal_sentiment.onnx) ort_out sess.run(None, { input_ids: batch_id.numpy(), attention_mask: batch_mask.numpy(), image: batch_img.numpy(), }) # 对比 ort_out[0] 与 torch_logits.detach().numpy()误差阈值 1e-3对拍时固定同一个 seed先加载数据再打乱保证两边的 batch 完全一致。误差超过 1e-2 时优先怀疑Normalize的 mean/std 在导出时被常量折叠改变了数值路径情感分类任务不应该有这么大偏差。如果还要压模型体积可以试 ONNX Runtime 的 int8 量化它对融合层影响很小但对 ImageNet 统计的 BN 层比较敏感量化后图像分支会小幅退化验证时记得分开看两个模态各自的精度变化。这个方案我反复用在商品评价和舆情场景里最大的体会是多模态模型的复杂度不在模型结构而在数据对齐和模态平衡。文本和图像单看都好的模型融合后不一定更好每一步都留好单模态基线和验证集划分才是省后悔药的关键。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进