☰
BERT+ResNet多模态情感分析实战:从数据预处理到融合策略全解析
2026/10/7 10:12:48 网站建设 项目流程

简介:面向计算机专业毕业设计、课程设计与期末大作业场景的多模态情感分析项目,采用预训练语言模型与卷积神经网络提取文本和图像特征,并实现多种特征融合策略,完整覆盖数据预处理、模型训练与评估流程。配套源码与文档说明,代码结构清晰,可直接运行,适合正在做毕业设计或需要实战项目的学习者。资源包共三十九个文件,包含十七份脚本文件、十二份编译后的二进制文件、三份说明文档、三份模型结构图、两份数据文件等,全部压缩后约四百四十五千字节,便于快速下载与部署。目前已有一百四十人学习。项目亮点在于提供了多种跨模态融合模型(如多头注意力融合、跨模态注意力机制等)的对比实现,并配有训练器、数据加工、配置文件等模块,能帮助读者理解多模态特征对齐与融合的核心思路,也便于在此基础上扩展改进。

1. 多模态情感分析:BERT 和 ResNet 融合到底解决了什么

情感分析在文本领域已经卷了很多年,但真实场景里的情感信号从来不只是文字。用户在短视频下面发一句“这个场景拍得真好”,配图是阴天和雨滴,语气和画面是冲突的;电商评论里“这颜色跟图片差远了”,文字是中性偏负,可配图里却拍出了质感和光影。单看文本,模型很容易判错。多模态情感分析要做的,就是把文本和图像放在同一个模型里去互补判断,而 BERT 负责理解语义、ResNet 负责抽取视觉特征,这组合在多数公开数据集上的表现都要比单模态高出 5 到 10 个点。这套资源就是一份完整的 Python 实现,包含 BERT + ResNet 的多种融合策略源码、训练脚本和文档说明,适合做期末大作业、课程设计,也适合刚接触多模态研究的同学拿来当基线框架。我拆完整个工程后最大的感受是:融合方式的选择、特征对齐的方式、以及训练时的显存控制,才是真正决定项目成败的地方,模型本身反而是最不需要纠结的部分。

2. 从单模态到多模态:先把特征提取和融合框架立住

2.1 为什么是 BERT 而不是 TextCNN,为什么是 ResNet 而不是 VGG

文本侧选 BERT,不是因为它在榜单上分数高,而是因为多模态融合需要一个能把上下文语义完整编码的向量。TextCNN、LSTM 这类模型输出的句子向量是词级别的池化结果,对“虽然…但是…”这种转折结构、对否定语义的捕获都比较弱。BERT 的 [CLS] 向量天然就是整个句子的语义压缩表示,而且可以直接用预训练权重初始化,在数据量不大时反而比从零训练 CNN 更稳。

图像侧选 ResNet,核心原因是残差结构在深层网络里梯度传递更稳定。多模态模型在训练时,图像分支往往要和 BERT 分支一起反向传播,BERT 本身已经是 12 层 Transformer,对梯度很敏感;如果图像分支再用 VGG 那种 16 层以上纯卷积堆叠,反传到底层时梯度很容易爆炸。ResNet 的跳跃连接相当于给梯度开了一条高速公路。我一般会用 ResNet50 而不是 ResNet101,因为情感分析任务里的图像特征是粗粒度的场景、颜色、物体情绪,不需要 101 层的细粒度特征。

2.2 源码的目录结构和核心模块划分

拿到工程后,先不要急着跑训练。先看目录结构,确认整个流程的骨架。

multimodal_sentiment/ ├── config.py # 全局配置:路径、超参数、模型参数 ├── dataset.py # 数据加载与预处理、图文对对齐 ├── models/ │ ├── text_encoder.py # BERT 封装 │ ├── image_encoder.py # ResNet 封装 │ └── fusion.py # 多种融合模块:拼接、门控、交叉注意力 ├── train.py # 训练主脚本 ├── evaluate.py # 评估脚本,输出分类报告和混淆矩阵 ├── predict.py # 单条样本预测 └── requirements.txt

config.py是整个工程的“总闸”,所有路径、批次大小、学习率、融合方式都从这一个文件里读取。工程里默认用的是data/文件夹,下面按train/、val/、test/三个子文件夹存放图文对,每个子文件夹里是一个 CSV 文件加一个图片目录。CSV 里至少要有text、image_path、label三列。数据格式不一致是拿到别人的代码后最常踩的坑,后面避坑部分我会细说。

2.3 数据预处理:文本截断和图像 Resize 的参数怎么设

文本侧,BERT 的输入有最大长度限制。dataset.py里默认把文本截断到 128 个 token,这个值是根据情感分析任务的经验设置的。调研显示,短文本数据集中超过 95% 的样本长度都在 128 token 以内,截断到 128 能在信息保留和显存开销之间取到平衡。如果数据集里存在大量长文本,可以改成 256,但要意识到 BERT 的 attention 计算量是序列长度的平方,显存吃紧时要先砍长度。

图像侧,ResNet50 的标准输入是 224x224。工程里用torchvision.transforms做尺寸调整和归一化。归一化的均值方差用的是 ImageNet 的默认值,因为加载的是 ImageNet 预训练权重。如果直接换用自己的统计值,反而会把预训练权重内部的特征分布打乱。

# dataset.py 中图像处理部分 from torchvision import transforms image_transform = transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize( mean=[0.485, 0.456, 0.406], # ImageNet 标准均值 std=[0.229, 0.224, 0.225] ) ])

Transform 里有一个很多教程都没提的细节:Resize默认是双线性插值,但对情感分析这种任务,被拉伸变形的图像会让物体比例失真。比如一张横构图蛋糕图片被压成正方形,蛋糕本身会变扁。虽然 ResNet 对形变有一定鲁棒性,但我在实际工程里更习惯用Resize之后再接CenterCrop,先等比缩放到短边 256,再中心裁剪 224。这个改动通常能给图像模态带来两个点左右的准确率提升。

3. 三种融合方法的工程实现:特征拼接、门控融合和交叉注意力

3.1 早期拼接融合:最朴素,但是最稳的基线

早期拼接融合的思路是:把 BERT 输出的 [CLS] 向量和 ResNet 输出的全局特征向量直接拼成一个长向量,再接全连接层分类。这个方案的代码实现只有几行,问题在于特征向量维度不匹配。BERT 的 hidden size 是 768,ResNet50 输出的特征维度是 2048,拼接后是 2816 维。

直接用 2816 维的向量喂全连接层,不是不行,但 2048 维的图像特征在数值范围上会主导分类结果。工程里对两个向量都做了 LayerNorm,然后拼接。这一步非常关键,做过对比实验的话,有 LayerNorm 比没有 LayerNorm 准确率高 2% 以上。

# models/fusion.py 中的早期拼接融合 import torch import torch.nn as nn class ConcatFusion(nn.Module): def __init__(self, text_dim=768, image_dim=2048, num_classes=3): super().__init__() self.text_norm = nn.LayerNorm(text_dim) self.image_norm = nn.LayerNorm(image_dim) self.classifier = nn.Sequential( nn.Dropout(0.3), nn.Linear(text_dim + image_dim, 256), nn.ReLU(), nn.Dropout(0.3), nn.Linear(256, num_classes) ) def forward(self, text_feat, image_feat): text_feat = self.text_norm(text_feat) image_feat = self.image_norm(image_feat) combined = torch.cat([text_feat, image_feat], dim=-1) return self.classifier(combined)

拼接后接一个 256 维的隐藏层,目的是让模型自己学习两个模态特征的交叉组合关系。这里的Dropout(0.3)是参考了多模态竞赛中的常见做法,0.2 到 0.5 之间都有人用。数据量小的时候,Dropout 往高了设,0.3 到 0.5 之间取一个值即可。

3.2 门控融合:让模型自己决定每个模态的权重

早期拼接的最大问题是,模型对所有样本都一视同仁。但实际的样本是分情况的:有的样本语言信息很强,图像只是氛围配图;有的样本完全依赖图像信息才能判断情感。门控融合的思路是,通过一个可学习的门控机制,对每个模态的特征计算出一个 0 到 1 之间的权重,然后用加权求和代替拼接。

# models/fusion.py 中的门控融合 class GateFusion(nn.Module): def __init__(self, text_dim=768, image_dim=2048, num_classes=3): super().__init__() self.gate = nn.Sequential( nn.Linear(text_dim + image_dim, 2), nn.Softmax(dim=-1) ) self.image_proj = nn.Linear(image_dim, text_dim) self.classifier = nn.Sequential( nn.Dropout(0.3), nn.Linear(text_dim, num_classes) ) def forward(self, text_feat, image_feat): image_proj = self.image_proj(image_feat) # 2048 -> 768,统一维度 gate_weight = self.gate(torch.cat([text_feat, image_proj], dim=-1)) fused = gate_weight[:, 0:1] * text_feat + gate_weight[:, 1:2] * image_proj return self.classifier(fused)

门控融合实现里,门控计算的输入是两个模态特征的拼接,把原始特征映射到 2 维,然后用 Softmax 归一化成两个权重。这个权重是批量计算、每个样本各自独立的,也就是说不同样本对文本和图像的依赖权重是不一样的。图像特征先通过一个线性层投影到 768 维,否则和一个 2048 维的向量做门控时,会引入大量参数,训练速度变慢。

这个方案在情感分析上的实际表现是:对于标题党和图像强相关的数据集,比拼接融合高 1% 到 2%;但如果数据本身图文关系比较弱,门控网络学到的权重会趋向于固定值,此时和拼接融合效果几乎一样。

3.3 交叉注意力融合:让文本和图像在 token 级别互相“看”

拼接和门控都是把整句话压缩成一个向量,再和整张图的全局特征做融合。但图里往往只有一个局部区域真正参与了情感表达,比如一张风景图里偏暗的角落,或商品图里褪色的细节。交叉注意力融合的核心思路是,让文本的每个 token 都和图像的多个局部区域计算注意力权重,再把加权后的图像特征和文本特征做融合。

# models/fusion.py 中的交叉注意力融合(简化版) class CrossAttentionFusion(nn.Module): def __init__(self, text_dim=768, image_dim=2048, num_heads=8): super().__init__() self.image_proj = nn.Linear(image_dim, text_dim) self.attn = nn.MultiheadAttention( embed_dim=text_dim, num_heads=num_heads, batch_first=True ) def forward(self, text_feat, image_feat, text_mask=None): # text_feat: [batch, seq_len, 768] # image_feat: [batch, 2048] -> 投影成 [batch, 1, 768] image_feat = self.image_proj(image_feat).unsqueeze(1) # query 是图像特征,key/value 是文本特征序列 attn_out, _ = self.attn( query=image_feat, key=text_feat, value=text_feat, key_padding_mask=text_mask ) return attn_out.squeeze(1)

交叉注意力的实现要拿到 BERT 的完整序列输出,不能只用 [CLS] 向量。text_feat的维度是[batch, seq_len, 768],取 BERT 最后一层的输出即可。attn_out是一个[batch, 1, 768]的向量,再和普通特征拼接或相加后进入分类器。

三个融合方式放到一起看,拼接融合最容易过拟合,门控融合收敛最快,交叉注意力效果上限最高但显存占用也最大。作为期末大作业,拼接融合加门控融合的组合已经足够覆盖“多种融合方法”的要求。

4. 训练流程和超参数调优:从 dataset 到 checkpoint 的完整链路

4.1 训练脚本的核心循环和参数说明

训练脚本train.py用的是 PyTorch 标准流程。数据加载部分,DataLoader的batch_size默认设成 8。这个数字不是拍脑袋定的,BERT-base 本身有 1.1 亿参数,ResNet50 有 2500 万参数,两个模型拼在一起,在单张 11GB 显存的卡上,batch_size 为 8 时刚好能跑起来。如果显存是 6GB,要么把 batch_size 降到 4,要么在 BERT 的forward里加入gradient_checkpointing。

# train.py 中的训练循环骨架 import torch from torch.utils.data import DataLoader from transformers import BertModel, BertTokenizer from torchvision.models import resnet50 device = torch.device("cuda" if torch.cuda.is_available() else "cpu") tokenizer = BertTokenizer.from_pretrained("bert-base-uncased") bert = BertModel.from_pretrained("bert-base-uncased").to(device) resnet = resnet50(pretrained=True).to(device) # 冻结部分参数的策略:BERT 底层 8 层不更新 for name, param in bert.named_parameters(): if "layer" in name and int(name.split(".")[2]) < 8: param.requires_grad = False

冻结 BERT 前 8 层,是数据量不足时最实用的技巧。BERT 预训练权重里的语言先验知识是通用的,底层网络学到的是语法结构,而不是任务相关的语义;只有接近输出层的高层网络才需要针对情感分析做微调。只更新最后 4 层的参数,可以显著减少显存占用和过拟合风险。如果你的数据量达到几万条,再考虑解冻全部层。

优化器我建议直接用 AdamW,学习率设为2e-5。BERT 微调有一个“学习率必须很小”的铁律,超过5e-5容易导致灾难性遗忘,模型会忘记预训练学到的通用语义。图像分支 ResNet 可以用相对大一点的学习率,比如1e-4。工程里用的是参数分组学习率,把 BERT 的参数和 ResNet 的参数分开用不同的学习率更新。

4.2 评估指标怎么选:准确率只是及格线

多模态情感分析常用的评估指标有准确率、F1 值和加权 F1。三分类情感标签(消极、中性、积极)往往存在类别不平衡,积极样本占比高得离谱,此时准确率没有参考意义。工程里evaluate.py输出的是一个完整分类报告,包括每个类别的 precision、recall 和 F1。

python evaluate.py --checkpoint ./checkpoint/best_model.pt --data ./data/test

运行评估脚本时,--checkpoint参数加载的是训练时保存的最优模型。工程里保存 checkpoint 的逻辑是:每个 epoch 结束后在验证集上算加权 F1,只有比历史最好的 F1 更高时才覆盖保存。这里不要用准确率作为保存依据,准确率偏向于样本多的类别,会导致最终模型在少数类上表现极差。

4.3 训练过程中的显存优化和梯度累积

显存不够是最常见的物理限制。如果训练时直接“CUDA out of memory”,别急着换显卡,先试梯度累积。梯度累积的思路是:模拟更大的 batch_size,但每次只过一个小的 batch,把梯度累加起来再统一更新参数。

# train.py 中梯度累积片段 accumulation_steps = 4 # 每 4 个 step 更新一次参数 optimizer.zero_grad() for step, batch in enumerate(train_loader): texts, images, labels = batch outputs = model(texts.to(device), images.to(device)) loss = criterion(outputs, labels.to(device)) loss = loss / accumulation_steps # 先除以累积步数,防止 loss 爆炸 loss.backward() if (step + 1) % accumulation_steps == 0: optimizer.step() optimizer.zero_grad()

实际显存只有原来八分之一的情况下,梯度累积可以让训练正常继续。代价是训练时间变长,因为每个 mini-batch 的前向和反向计算都得走一遍,只是参数更新次数变少了。操作时要注意,loss要除以accumulation_steps,否则相当于把学习率放大了 4 倍,模型大概率不收敛。

5. 避坑指南:多模态训练里最要命的四个坑

5.1 图文数据对不齐:Index 错位但训练不报错

现象:训练 loss 在逐步下降,但验证集 F1 始终在 0.3 左右徘徊,接近随机猜测。检查数据时发现文本内容明显和图片无关。

原因:dataset.py用 pandas 读取 CSV 后,部分数据集做数据清洗时删除了空行,但.reset_index()没执行。batch里取image_id时用的是原来 CSV 的行号,删除行之后行号错位了,取到的图片是别的样本的。

解决:在读取 CSV 后立刻执行df = df.reset_index(drop=True),并且在Dataset.__getitem__里打印几个样本,做人肉对齐检查。

# dataset.py 中对齐检查 class MultimodalDataset(torch.utils.data.Dataset): def __init__(self, csv_path, img_root): self.df = pd.read_csv(csv_path).reset_index(drop=True) # 检查逻辑:随机抽样 5 个样本,打印 text 和 image_path sample = self.df.sample(5, random_state=42) for _, row in sample.iterrows(): print(row["text"], "->", row["image_path"])

5.2 BERT 输入长度超限导致截断后语义反转

现象:训练正常,但遇到长文本样本时预测结果特别离谱。一条文本前半段是“这家餐厅环境很好、服务到位”,后半段是“但是菜太难吃了”,模型预测成了积极。

原因:BERT tokenizer 默认截断是“从头保留 128 个 token”,后半段的转折信息直接被砍掉了。情感分析里转折连词之后的内容往往是情感判断的关键。

解决:用truncation=True参数时,同时设置return_overflowing_tokens=True,或者把截断策略改成从尾部保留,工程里提供了一个长文本分段处理的函数。如果非要快速修,直接把 max_len 从 128 调到 256,并且截断参数设为truncation_side="right"之前,先确认你的数据里长文本占比。

# tokenizer 调用时的正确姿势 inputs = tokenizer( text, max_length=128, truncation=True, padding="max_length", return_tensors="pt" ) # 如果文本明确用了转折结构,建议在预处理阶段就做分段

5.3 ResNet 预训练权重加载失败:版本不匹配

现象:resnet50(pretrained=True)直接报错,或在加载权重时出现Missing key(s) in state_dict。

原因:新版 torchvision 把pretrained参数标记为弃用,换成weights参数。部分旧代码用的model_zoo下载链接已失效。

解决:把写法改为resnet50(weights=ResNet50_Weights.IMAGENET1K_V2)。需要注意,用新接口加载的权重和旧接口加载的权重在数值上几乎完全一样,不用重新跑实验对比。

from torchvision.models import resnet50, ResNet50_Weights resnet = resnet50(weights=ResNet50_Weights.IMAGENET1K_V2) # 旧代码写成 resnet50(pretrained=True),新版 torchvision 直接报 warning

5.4 多模态特征维度不匹配:低级错误但特别耗时

现象:前向传播报错,mat1 and mat2 shapes cannot be multiplied,集中在融合模块。

原因:BERT 输出 hidden state 时,没搞清楚该取last_hidden_state[:, 0](即 [CLS])还是pooler_output。实际上pooler_output在多数 BERT 微调任务里表现不如直接取 [CLS];但维度都能对应上。真正的问题是 ResNet 的forward返回结果是[batch, 1000],而不是[batch, 2048],因为有人错误地加载了 ResNet 的分类头,或者把resnet.fc替换掉了。

解决:在 ResNet 封装里去掉最后一层全连接,取avgpool输出,统一转成[batch, 2048]。

# models/image_encoder.py 中正确的 ResNet 特征提取 class ResNetEncoder(nn.Module): def __init__(self): super().__init__() self.backbone = resnet50(weights=ResNet50_Weights.IMAGENET1K_V2) self.backbone.fc = nn.Identity() # 去掉最后一层全连接 def forward(self, x): features = self.backbone(x) # 此时输出 [batch, 2048] return features

6. 把训练好的模型用到自己的数据上:预测脚本和一个检查习惯

拿到工程后,把训练跑通只是第一步。真正写进课程设计报告里的能力,是把模型迁移到自己的数据上做预测。工程里的predict.py已经封装好了单条样本的完整流程,核心逻辑是先加载训练时保存的 checkpoint,恢复模型权重,然后对输入的文本和图片走一遍前向传播。

# predict.py 的核心调用 import torch from PIL import Image from models.fusion import GateFusion def predict_single(model, tokenizer, text, image_path, device): # 文本编码 inputs = tokenizer( text, max_length=128, truncation=True, padding="max_length", return_tensors="pt" ).to(device) # 图像编码 image = Image.open(image_path).convert("RGB") image_tensor = image_transform(image).unsqueeze(0).to(device) # 分别过两个 encoder,再融合 with torch.no_grad(): text_feat = bert(inputs["input_ids"], attention_mask=inputs["attention_mask"]) text_feat = text_feat.last_hidden_state[:, 0, :] image_feat = resnet(image_tensor) logits = model(text_feat, image_feat) label = torch.argmax(logits, dim=-1).item() return label

使用预测脚本时,有一个动作特别容易被忽略:模型加载后必须调用model.eval()。如果忘记设置,模型里的 Dropout、BatchNorm 会保持在训练模式,预测结果每次跑都会有些微不同,而且偏差可能不是一点点。我自己的习惯是把模型推理写成一个独立函数,函数第一行就是model.eval(),把这个当成条件反射。

验证模型是否真正学到了多模态能力,有个很实用的反事实测试:把文本替换成同样情感极性但完全不同表述的句子,看模型输出是否稳定;把图像换成纯色噪声图,看模型是否更依赖文本。两个测试都做一遍,如果换图之后结果变化不大,说明模型对图像模态的利用是无效的,需要回头检查 ResNet 分支是否真的参与了梯度更新。

我在跑这个项目的过程中发现,工程里默认的 checkpoint 保存逻辑在验证集上做了早停,但未对测试集做多次运行取平均。多模态模型对随机种子比较敏感,同一个数据跑两次,F1 波动可能有 1 到 2 个点。我后来在训练脚本里加了一个--seed参数,每个实验跑三次取平均和标准差,写进报告里会有说服力得多。从那以后,我每次拿到新的多模态工程,第一件事不是看模型结构,而是先跑通 predict 脚本和反事实测试,确认特征真的在两个模态之间流动起来了,再去调融合方式。希望这份源码和这份拆解,能帮你在期末作业或课程设计里少走几段弯路。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询