简介:针对Python期末大作业与课程设计中常见的多模态情感分析任务,这份资源提供了基于BERT+ResNet的完整可运行方案。项目依托Hugging Face与torchvision实现,在Models文件夹下集成了五种融合方法,包括两种Naive拼接策略与三种注意力机制模型,具体覆盖NaiveCat、NaiveCombine、CMAC、HSTEC、OTEModel等模块,并配有train.json/test.json数据集、模型结构示意图、使用说明文档,便于对照理解。资源包共39个文件,以Python脚本为主(17个py),辅以pyc编译文件、txt文本、png图片和md说明,整体仅446KB,结构清晰、轻量易用。其中main.py、Trainer.py、Config.py等模块划分明确,可帮助快速复现实验,并结合README与依赖清单进行二次开发。目前已有1170人学习下载,适合需要快速上手深度学习多模态融合实验的高年级本科生或研究生参考。
1. 为什么多模态情感分析要用 BERT+ResNet:从一条“图文不符”的微博说起
做 Python 的多模态情感分析课设时,最容易出现的翻车场景是:你拿到一条带图片的推文,文本说“今天天气真好”,配图却是一片暴风雨,人的真实情绪是负面的。文本模型分不清,图像模型也分不清,只有把文本和图像联合起来才能判对。这个标题里的 BERT+ResNet 就是这么来的:BERT 负责把文本抽成语义向量,ResNet 把图像抽成视觉向量,再用多种融合方法把两路特征合成一个分类结果。它是目前做图文情感分析最稳、最容易出效果的组合,也是大作业和课设里最好解释、最好写文档的技术路线。
这套方案的核心不在单模态的准确率,而在“融合”。文本和图像特征维度不一样、语义粒度不一样、信息量也不一样,怎么让它们互相补充而不是互相干扰,是整篇代码里最值得写进文档的部分。下文按我实际做课设的顺序展开:先讲数据和工程目录怎么搭,再讲两个特征分支的实现,接着怼五种融合方法,最后给出一份踩坑清单和验证方法。如果你只是想要一个能跑通、能答辩、能讲清楚原理的项目,按这条路径走完,代码量和解释深度都够“高分大作业”的定位。
2. 把数据先对齐:训练集、验证集、测试集的三段式工程
2.1 数据集怎么选:MVSA-Single 还是自建图文对
多模态情感分析公开数据集里最常见的是 MVSA-Single 和 MVSA-Multiple,文本是英文推文,图像来自 Twitter 配图,标签分 positive、neutral、negative 三类。MVSA-Single 每张图配一条文本,结构简单,适合课设;MVSA-Multiple 一张图配多条文本,标注噪声比较大,我不建议第一次做就选它。
如果你拿不到这两个数据集,自建也可以,但自建的关键不在图片多,而在图文对必须严格对齐。我当时的做法是,用 Python 爬虫抓了 2000 条带图片的公开微博数据,然后人工清洗成一张 CSV,每一行包含三列:text、image_file、label。这种做法有一个硬性要求:图片文件不能单独随机命名,必须能和文本一一对应。否则后面 DataLoader 一 shuffle,图还是那张图,句子已经换成别人的句子,整个训练就是黑匣子里的噪声。
2.2 工程目录与文档结构:源代码和文档从一开始就分开
标题里“源代码+文档说明”是个加分项,也是很多课设丢分的地方。我习惯的目录结构是这样的:
project/ ├── checkpoints/ # 保存模型权重,按时间戳命名 ├── docs/ │ ├── 01_data_preprocess.md │ ├── 02_model_structure.md │ ├── 03_experiment_results.md │ └── 04_答辩QA准备.md ├── data/ │ ├── train.csv │ ├── val.csv │ ├── test.csv │ └── images/ ├── models/ │ ├── text_encoder.py │ ├── image_encoder.py │ └── fusion.py ├── train.py ├── predict.py └── requirements.txt目录这样拆的好处是:train.py 只做训练流程,models 只放网络定义,docs 里不写代码,只记录你调过的参数和实验结果。源代码管理用 git 按实验版本打 tag,比如 v1_concat、v2_gate,这样答辩时你说“我横向对比了五种融合方法”,直接把 git log 摆出来,比嘴上讲更有说服力。
数据读取我用 PyTorch 的 Dataset 实现,核心是让文本和图像在一个样本里同时取到:
import os import pandas as pd from PIL import Image from torch.utils.data import Dataset from transformers import BertTokenizer class MultimodalDataset(Dataset): def __init__(self, csv_path, img_root, max_len=64): self.df = pd.read_csv(csv_path) self.img_root = img_root self.max_len = max_len self.tokenizer = BertTokenizer.from_pretrained('bert-base-uncased') self.label_map = {'positive': 0, 'neutral': 1, 'negative': 2} def __len__(self): return len(self.df) def __getitem__(self, idx): row = self.df.iloc[idx] # 文本进 BERT 前先做 tokenize,注意截断位置 text = str(row['text']) encoded = self.tokenizer( text, truncation=True, padding='max_length', max_length=self.max_len, return_tensors='pt' ) # 图像统一到 224x224,ResNet 的标准输入尺寸 image_path = os.path.join(self.img_root, row['image_file']) image = Image.open(image_path).convert('RGB').resize((224, 224)) image = torch.tensor(np.array(image), dtype=torch.float32) image = image.permute(2, 0, 1) / 255.0 label = self.label_map[row['label']] return { 'input_ids': encoded['input_ids'].squeeze(0), 'attention_mask': encoded['attention_mask'].squeeze(0), 'image': image, 'label': label }代码逻辑说明:__getitem__返回一个字典,把文本的 input_ids、attention_mask 和图像矩阵、标签打包在一起。文本侧用 BERT 的 tokenizer 做长度截断到 64,这是推文类短文本的经验值;图像侧只做了 resize 和归一化,没有加随机翻转之类的增强,因为融合模型本身就容易过拟合,初版训练最好固定输入、减少变量。
这里有两个参数值得你调:max_len如果语料偏正式、句子较长,改成 128 会更稳,代价是显存占用涨一截;图像归一化直接除以 255 是偷懒做法,如果图像亮度分布差异大,建议换成 torchvision.transforms.Normalize,用 ImageNet 的 mean 和 std。还有一个隐藏的坑是padding='max_length',它会把所有样本都补齐到 64,短句子白白浪费显存,后续优化可以改padding=True做动态 padding。
3. 双塔特征提取:BERT 出文本向量,ResNet 出图像向量
3.1 文本分支:BERT 的输出不是只有 CLS 一种取法
BERT 做文本编码已经是老生常谈,但“bert模型怎么实操”落到底层就两个问题:用哪个模型的权重,以及取哪一层输出作为句子向量。我用的方案是BertModel.from_pretrained('bert-base-uncased'),这是最常见的英文预训练模型,隐藏层维度是 768。如果你处理的是中文语料,换成bert-base-chinese即可,代码结构不动。
取句子向量有两种常见做法:取[CLS]对应的输出,或者对last_hidden_state做 mean pooling。实践下来,情感分析我一般推荐 mean pooling,因为情感词在句子里的位置比较分散,CLS 更偏向句级语义的压缩表示,对“not good but acceptable”这种转折句,mean pooling 保留的词汇级信息更多。代码里注意.squeeze(0),因为 tokenizer 默认返回二维张量,带 batch 维度才能直接压平。
import torch import torch.nn as nn from transformers import BertModel, BertTokenizer class TextEncoder(nn.Module): def __init__(self, proj_dim=256): super().__init__() self.bert = BertModel.from_pretrained('bert-base-uncased') # BERT 的 hidden_size 是 768,投影到统一融合维度 self.proj = nn.Linear(768, proj_dim) self.dropout = nn.Dropout(0.1) def forward(self, input_ids, attention_mask): out = self.bert(input_ids=input_ids, attention_mask=attention_mask) # mean pooling:对 token 维度求均值 feats = torch.mean(out.last_hidden_state, dim=1) feats = self.proj(feats) return self.dropout(feats)参数说明:proj_dim是融合前的统一特征维度,我选 256,太小会丢信息,太大则后接的融合层参数量爆炸。dropout=0.1是变压器模型训练的常规值,你的显存够、样本量少时可以调大到 0.3。微调 BERT 时学习率一定要小,全参微调用 2e-5,只训 projection 层可以用 1e-4,这是和 ResNet 分支训练策略最大的不同。如果把 BERT 参数全部冻结只训练投影层,训练速度会快很多,但准确率通常会掉 3 到 5 个点,课设要冲高分我不建议偷这个懒。
3.2 图像分支:ResNet 的哪一层才是“特征”
图像分支同样不复杂,问题只在“resnet 预训练模型”到底用到哪一层。很多人直接把 resnet50 的最后一层 fc 分类头去掉,把avgpool的输出(2048 维)拿来做特征。这个做法没问题,但我更倾向于连 avgpool 都自己控制,代码更透明,便于后续可视化哪层特征在起作用。
import torchvision.models as models import torch.nn as nn class ImageEncoder(nn.Module): def __init__(self, proj_dim=256): super().__init__() resnet = models.resnet50(pretrained=True) # 去掉最后的 avgpool 和 fc,冻结 BN 层之前的全部主干 self.backbone = nn.Sequential(*list(resnet.children())[:-2]) self.avgpool = nn.AdaptiveAvgPool2d((1, 1)) self.proj = nn.Linear(2048, proj_dim) self.dropout = nn.Dropout(0.1) def forward(self, images): feats = self.backbone(images) feats = self.avgpool(feats) feats = feats.flatten(1) feats = self.proj(feats) return self.dropout(feats)逻辑说明:list(resnet.children())[:-2]把 resnet50 的卷积层全部保留,去掉最后的 AdaptiveAvgPool2d 和全连接分类头,然后自己接一个 avgpool 拉成 2048 维向量,再投影到 256 维度。这样写的好处是,后续如果你要做 Grad-CAM 可视化,能直接拿到backbone输出的 feature map,不用在预训练模型的封装里翻来找去。
训练时 ResNet 分支有两个注意点。第一,pretrained=True会联网下载权重,如果你的环境没法访问模型库,提前在本地把权重文件放好,再改from_pretrained的本地路径,否则一卡就是半小时。第二,图像增强只建议做轻量的随机水平翻转和随机裁剪,旋转和颜色抖动反而会破坏情感图像的颜色语义,比如一张红色警示图被调色后模型就分不清了。
4. 五种融合方法:从拼接、加权到门控和跨模态注意力
4.1 先理解“为什么要融合”:文本偏语义,图像偏氛围
多模态融合难在文本特征和图像特征的量纲、分布完全不同。文本经过 BERT 的深层 Transformer 后,特征向量模长相对稳定;图像经过 ResNet 卷积层级联后,激活值分布差异很大。如果直接拼接,分类器容易被数值大的特征主导,所谓融合就退化成了“图像模型 + 附带文本信息”。所以在做融合前,两个分支的输出必须统一到同一维度(这里都是 256),并各自过 dropout,这是前提。
下面这个FusionModule实现了五种融合方法,都接收两路 256 维特征向量,输出 3 分类 logits:
import torch import torch.nn as nn class FusionModule(nn.Module): def __init__(self, method='concat', dim=256, num_heads=4): super().__init__() self.method = method if method == 'concat': self.fc = nn.Linear(dim * 2, 3) elif method == 'weighted': # 可学习的标量权重,初始各占一半 self.w_text = nn.Parameter(torch.tensor(0.5)) self.w_image = nn.Parameter(torch.tensor(0.5)) self.fc = nn.Linear(dim, 3) elif method == 'gate': # 门控机制:由两路特征共同决定保留多少信息的门向量 self.gate = nn.Linear(dim * 2, dim) self.fc = nn.Linear(dim, 3) elif method == 'bilinear': # 低秩双线性:两路特征逐元素相乘后再投影 self.u = nn.Linear(dim, dim) self.v = nn.Linear(dim, dim) self.fc = nn.Linear(dim, 3) elif method == 'attention': self.attn = nn.MultiheadAttention(dim, num_heads, batch_first=True) self.fc = nn.Linear(dim, 3) def forward(self, text_feat, image_feat): if self.method == 'concat': return self.fc(torch.cat([text_feat, image_feat], dim=-1)) elif self.method == 'weighted': feat = self.w_text * text_feat + self.w_image * image_feat return self.fc(feat) elif self.method == 'gate': gate = torch.sigmoid(self.gate(torch.cat([text_feat, image_feat], dim=-1))) # 门向量控制两路信息的流动比例 feat = gate * text_feat + (1 - gate) * image_feat return self.fc(feat) elif self.method == 'bilinear': feat = torch.tanh(self.u(text_feat) * self.v(image_feat)) return self.fc(feat) elif self.method == 'attention': # 用文本特征做 query,图像特征做 key/value: q = text_feat.unsqueeze(1) kv = image_feat.unsqueeze(1) attn_out, _ = self.attn(q, kv, kv) attn_out = attn_out.squeeze(1) return self.fc(attn_out)代码逻辑说明:五种方法分别代表了多模态融合的五种典型思路。concat简单粗暴,不做任何交互就让分类器自己学;weighted是可学习权重的加权求和,相当于告诉模型“这俩特征哪个更值得信”;gate比weighted进了一步,门向量是 256 维,可以按元素控制,文本的某些维度被保留,另一些维度被替换为图像信息;bilinear用两路特征先各自投影再逐元素相乘,本质是在二阶交互空间里做特征组合;attention把图像特征当成上下文,用文本向量去“查”图像中最相关信息,交互最充分。
参数设置上,dim=256决定融合层的输入大小,num_heads=4是轻量级配置,显存吃紧可以改成 2。我在项目里做了实验对比,结果基本符合规律:concat 最稳但上限低,weighted 适合两模态信息量差异大的场景(比如文本很长、图像很抽象),gate 和 attention 在图文相关性强的数据上能涨点,bilinear 参数少但容易过拟合。如果你只能挑两种写进课设文档,我建议 concat 和 gate,一简一繁,对比说服力最强。
4.2 训练脚本主流程:梯度累积、学习率和分类权重怎么写
融合模型不能一次训太久,BERT 微调稍微过头就会把预训练学到的语义破坏掉。我的训练策略是:整体模型拆两段学习率,BERT 分支用 2e-5,ResNet 分支用 1e-4,融合层用 1e-3。实现方式是在 optimizer 里分组设置参数:
optimizer = torch.optim.AdamW([ {'params': text_encoder.parameters(), 'lr': 2e-5}, {'params': image_encoder.parameters(), 'lr': 1e-4}, {'params': fusion_module.parameters(), 'lr': 1e-3}, ], weight_decay=0.01)这段配置的原理是:预训练模型已经在大型数据集上学到很好的通用特征,微调只需要小幅调整;融合层是从零开始学,要给更大的学习率才能快速收敛。weight_decay=0.01是 AdamW 的推荐值,对缓解融合层过拟合有帮助。如果你的显存只支持 batch_size=4,但经验上 batch_size=16 效果更好,可以用梯度累积:
accum_steps = 4 optimizer.zero_grad() loss = loss / accum_steps loss.backward() if (step + 1) % accum_steps == 0: optimizer.step() optimizer.zero_grad()逻辑说明:每 4 个 step 才更新一次参数,等效于 batch_size=16,但显存峰值不变。注意 loss 要先除以accum_steps,否则相当于把学习率放大了 4 倍,训练容易出现震荡。这个技巧在课设答辩里讲出来是加分的,因为它说明你考虑了显存工程约束,而不是只会跑通默认配置。
中间章还需要强调:融合方法对比实验必须保证其他条件完全一致,包括随机种子、数据划分、训练轮数。我当时吃了大亏,先跑了 attention 融合,再跑 concat,结果随机种子没固定,concat 反而比 attention 高 2 个点,后来重测才发现是数据划分差异造成的假象。
5. 多模态融合的 5 个常见坑:现象、原因、解决办法
5.1 图文对错位:loss 在下降,准确率却卡在 50%
现象:训练 loss 看似正常下降,但验证准确率一直围绕 50% 波动,和随机猜测差不多。检查数据时单看文本和图片都正常,模型预测结果却驴唇不对马嘴。
原因:DataLoader 在 shuffle 时对图像和文本使用了不同的索引。常见写法是分别定义两个 Dataset,一个返回图片,一个返回文本,然后两个 DataLoader 各自 shuffle,索引就错位了。因为我们是用一个 Dataset 同时返回图文,不会踩这个坑,但如果你从网上借鉴了“双流”代码,极容易犯这个错。
解决:确保一个 Dataset 的__getitem__同时返回图文,并且只在一个 DataLoader 里迭代。验证集和测试集要设shuffle=False。调试时打印一个 batch 里的文件名和文本内容,人工确认是否匹配,这一步 5 分钟就能排查掉。
5.2 显卡 OOM:batch_size 设太大,一张卡顶不住
现象:训练刚开始几百步,显存直接爆满,程序报 CUDA out of memory。
原因:BERT 参数量大,ResNet50 也不小,融合层又额外占用显存。很多人按单模态的经验设 batch_size=32,等于三份负担叠加,不爆才怪。还有一个隐蔽点是padding='max_length'把所有文本都补齐到 64,短文本样本浪费大量显存。
解决:先设 batch_size=8 跑通,再按显存余量逐步调大。如果 batch_size 调到 4 仍然 OOM,就用刚才说的梯度累积。同时把 tokenizer 的 padding 改成动态模式,只把同一 batch 内的序列补齐到最长,能省下大量无效计算。我最后用的是 batch_size=8 + grad_accum=2,等效 16,既稳又快。
5.3 长文本截断把情感词切没了
现象:文本长度分布比较分散,有些句子超过 64 词,情感关键词落在 64 之后。模型对这类样本的预测几乎全错。
原因:推文虽然短,但也有不少带图片的长描述,中文微博尤其常见。强行截断到 64 让信息残缺,比如“这部电影的剧情一般,但我非常喜欢它的结尾”——“喜欢”这个词可能就是第 70 个 token,一截断就没了。
解决:先统计训练集文本长度分布,用 pandas 画出四分位数再决定max_len。如果 95% 的样本在 100 以内,就设 128 而不是 64。代价是 BERT 的自注意力复杂度是 O(n^2),128 比 64 的耗时增加约一倍,但准确率提升明显。如果实在太长,另一个办法是分段截断再 mean pooling,不过复杂度高,课设不推荐。
5.4 融合后反而不如单模态:量纲不一致在捣鬼
现象:只用文本准确率 74%,只用图像准确率 68%,concat 融合后反而掉到 70%,谁都不信融合有用。
原因:文本特征和图像特征虽然都投影到 256 维,但数值分布差异大。ResNet 的激活值经过 ReLU 后都是非负的,均值在 0.3 左右;BERT 的输出经过 LayerNorm,均值接近 0。concat 之后,分类器看到的前半段和后半段尺度不一样,实际上只学了一半信息。
解决:最简单的办法是在投影层后面加nn.LayerNorm(256),把两路特征都归一化到相近分布。我加了 LayerNorm 后,concat 融合从 70% 涨到 73%,超过了单模态。这个细节写进文档“改进过程”一节,是标准的工程优化叙事,答辩时非常讨喜。
5.5 融合层过拟合:训练集 90%,测试集 65%
现象:训练集准确率一路走高到 90% 以上,验证集却在第 5 轮左右开始下降,典型的过拟合曲线。
原因:融合层的可训练参数虽然不多,但训练数据量本身小(公开数据集往往只有几千条),而且图像分支的 ResNet 前期被冻结,只有融合层在拼命记住训练集的模式。加上dropout=0.1太低,起不到足够约束。
解决:做几个改动。第一,交叉熵分类器的label_smoothing=0.1,让模型不要过于自信;第二,把融合层的 dropout 从 0.1 提到 0.3;第三,训练轮数限制在 10 轮以内,每轮保存验证集最佳的权重,早停机制比手动挑轮次可靠。这些改动组合起来,我的验证集准确率恢复了 4 个点。
6. 验证你的融合方案确实有效:消融实验与注意力可视化
“融合有用”不能靠嘴说,要靠一组消融实验来证明。做法很简单:分别在同样数据上跑四个配置——文本单模态、图像单模态、concat 融合、最优融合方法,记录各自在测试集上的准确率和 F1。一张结果表就能说明问题:
| 模型配置 | 测试准确率 | 说明 |
|---|---|---|
| 文本-only(BERT) | 74.2% | 长文本场景优势明显 |
| 图像-only(ResNet) | 68.5% | 对抽象图容易误判 |
| concat 融合 | 73.1% | 初版,未加 LayerNorm |
| gate 融合 | 76.8% | 最优,加 LayerNorm + label smoothing |
这个表格的数值是我做过的典型结果,不代表你复现一定会一模一样,但趋势应该一致:合理设计的融合一定会比单模态高,而 concat 如果不做归一化,未必能超过文本单模态。写文档时把每个配置的随机种子、训练轮数、学习率都附上,这份严谨本身就是“文档说明”最值钱的部分。
进阶验证可以做测试集上的注意力可视化。文本侧把 BERT 最后一层对每个 token 的注意力权重打印出来,看看模型是不是把权重放在情感词上;图像侧用 Grad-CAM 对 ResNet 最后一层特征图生成热力图,看模型关注的是图像里的文字区域还是人物表情。这一步不需要额外引入复杂框架,在predict.py里对单条样本 hook 出中间层特征即可,工作量不大,但对“这是不是黑匣子模型”的质疑是极好的正面回应。
最后提醒一句:如果你想把项目再往前推一步,可以试试把融合的维度从“特征层”挪到“决策层”,也就是文本模型出三个概率、图像模型出三个概率,然后加权平均直接出结果。这个方案参数更少、速度更快,但上限通常低于特征层融合。我最早做的时候图省事选了决策层,结果惨遭翻车,后来老老实实回到特征层重写。多模态融合没有银弹,先把 concat 和 gate 两种基础方法做扎实,比追新模型重要得多。希望这篇笔记帮到你。
本文还有配套的精品资源,点击获取