ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

BERT+ResNet多模态融合的情感分析实战

BERT+ResNet多模态融合的情感分析实战 简介面向毕设、课程设计与工程实训的多模态情感分析项目基于Python实现BERTResNet50融合框架内置2种朴素融合与3种注意力融合方法覆盖文本与图像模态的特征提取与交互建模并配套数据划分、训练配置与依赖环境。资源共40个文件以17个Python源码脚本为核心涵盖模型定义、训练器、数据处理与API接口另有3张模型结构图、3个TXT说明/数据、2个JSON数据划分文件及README文档整体仅470KB轻量便于快速上手。已有558人学习下载适合中小型项目起步或算法对比实验。通过Models文件夹可查看五种融合实现包括朴素拼接、朴素组合、跨模态注意力及两种Transformer编码器融合方法配合Config.py、Trainer.py与数据预处理模块可完整体验从数据加载、模型训练到结果评估的流程同时附带结构示意图与依赖清单帮助读者理清跨模态注意力机制并基于自身数据二次开发。整体目录结构简明模型与数据分离替换数据集及调整超参数即可开展迁移实验。1. 从单模态误判到多模态融合为什么情感分析需要同时看文字和图像一条“这家店太赞了”的评论配上空无一人的餐厅照片情感方向可能完全反转只看文本或只看图像的单模态模型必然在这种场景犯错。基于 Python 把 BERT 和 ResNet 接到同一个训练流程里做多模态情感分析要解决的并不是“再堆一个模型”而是文本特征和视觉特征在哪个层级、用哪种方式合起来才能让信息互补而不互相淹没。下面内容默认你已经能跑通 PyTorch 基础训练也了解 transformers 里的 BERT 模型和 resnet 预训练模型我会从融合方法选型、数据对齐、双塔实现到消融验证给出一条可以复现的完整路径。2. 融合方法的三个层级与选型特征拼接、门控与跨模态注意力多模态融合并不是把两个向量直接接起来就结束。BERT 输出的文本向量和 ResNet 输出的视觉向量在统计分布、维度和信息粒度上完全不同直接torch.cat会让 MLP 花大量参数去学习两个分布之间的映射。常见做法是把融合分成三个层级逐个试早期特征拼接、带门控的加权融合、以及跨模态注意力。我一般会先跑拼接和门控把注意力放在数据量充足或单模态基线已经不错时再加。2.1 双塔结构BERT 与 ResNet 各自产出什么特征用 Hugging Face 加载BertModel.from_pretrained(bert-base-uncased)后last_hidden_state的形状是[batch, seq_len, 768]pooler_output是把[CLS]过 tanh 后的[batch, 768]。很多分类代码直接用pooler_output但在多模态场景里我保留last_hidden_state原因很简单后面做跨模态注意力时文本侧需要和图像特征在 token 级位置上对齐而不是只留一个汇总向量。视觉侧用torchvision.models.resnet50(weightsResNet50_Weights.IMAGENET1K_V1)把最后一层fc去掉前向输出就不是 1000 类 logits而是[batch, 2048, 7, 7]的特征图。这个特征图展平后长度是 49相当于把图像切成了 49 个局部区域ResNet 的每个通道都在描述某个空间位置有没有对应纹理。视觉特征和文本 token 序列在结构上非常接近这是后续能互相 attend 的基础。特征来源输出形状说明BERTlast_hidden_state[B, T, 768]T 是文本长度保留完整序列BERTpooler_output[B, 768]压缩后的句子向量丢失局部信息ResNet 特征图[B, 2048, 7, 7]空间 grid 49等价于图像 tokenResNet 平均池化后[B, 2048]全局视觉向量不保留位置两个塔的原始维度分别是 768 和 2048融合前我都会用nn.Linear投影到同一个维度比如 256。这样拼接、加法和注意力都处在同一向量空间门控也有物理意义。2.2 三种常用融合方法怎么选拼接、门控、跨模态注意力下面这张表是我做选型的基准。数据量小、只想先看多模态有没有收益时选特征拼接某个模态噪声明显时选门控文本和图像有明确局部对应关系比如“空无一人的店铺”对应图像里的桌椅区域选跨模态注意力。方法交互方式参数量适用场景特征拼接[text; vision]过 MLP最小快速出基线判断模态是否互补门控融合sigmoid 门控加权中等模态质量不一致需抑制噪声跨模态注意力文本 query 视觉 key/value最大需要局部对齐如反讽、细粒度描述一个可运行的简化版融合层如下三个分支由method参数切换import torch import torch.nn as nn class SimpleFusion(nn.Module): def __init__(self, text_dim768, vision_dim2048, hidden256, methodgated): super().__init__() self.text_proj nn.Linear(text_dim, hidden) self.vision_proj nn.Linear(vision_dim, hidden) self.method method if method concat: self.neck nn.Linear(hidden * 2, hidden) elif method gated: self.gate nn.Linear(hidden, hidden) elif method cross_attn: self.attn nn.MultiheadAttention(hidden, 4, batch_firstTrue) self.head nn.Linear(hidden, 1) def forward(self, text_vec, vision_vec): # text_vec: [B, 768]vision_vec: [B, 2048]向量版示意 t self.text_proj(text_vec) v self.vision_proj(vision_vec) if self.method concat: h torch.cat([t, v], dim-1) h torch.relu(self.neck(h)) elif self.method gated: g torch.sigmoid(self.gate(t)) h g * t (1 - g) * v elif self.method cross_attn: # 向量版需要把 [B, hidden] 变成 seq_len1 h, _ self.attn(t.unsqueeze(1), v.unsqueeze(1), v.unsqueeze(1)) h h.squeeze(1) return self.head(h)代码里的text_vec和vision_vec是已经池化好的向量所以 cross_attn 分支把 seq_len 设为 1只作为原理演示。真实项目我会在第四章用 ResNet 的特征图做 49 个位置上的注意力直接对特征图做交互。参数说明hidden256是投影维度过小丢信息过大在数据量不足时容易过拟合num_heads4在hidden256时每个头分到 64 维比 8 头更稳。2.3 融合位置决定了模型容量和训练难度拼接和门控属于晚期融合发生在两路都池化成向量之后跨模态注意力属于早交互发生在 token 序列和空间特征图上。早交互让文本的每个词都能看到图像的局部区域但也意味着梯度要穿过注意力层、投影层再回到 BERT 的深层训练难度明显更高。我的经验是先跑一个concat基线F1 如果比两个单模态的最高值还低问题多半出在数据对齐而不是融合方法。这时候不要直接上注意力先检查图像路径是否对得上、文本是否被截断、标签是否平衡。只有基线收益正向再试 gate 和 cross_attn否则很难定位是融合结构的问题还是数据的问题。3. 用 Python 完成多模态数据对齐环境准备、CSV 与预处理数据对齐是多数多模态项目第一次翻车的地方。文本和图像来源不同token 序列长度和图像尺寸也不同必须在进入模型前统一成固定 batch。先搭一个干净的 Python 环境再把 CSV、图像路径、tokenizer 和 transform 串起来。3.1 环境准备conda 创建 Python 3.8 环境并安装依赖我一般用 conda 单独建环境避免把 BERT 相关的 transformers 依赖装进系统 Pythonconda create -n mma python3.8 -y conda activate mma pip install torch torchvision transformers pandas pillowpython3.8不是必须但很多旧代码和编译好的 torch 二进制在这个版本下行为最一致如果你不想受限制用 3.10 也可以关键是别混装。装完后在 VSCode 里按CtrlShiftP选择解释器路径通常是~/anaconda3/envs/mma/bin/python。常见的坑是终端里conda activate mma生效了但 VSCode 的 Python 插件仍指向 base 环境运行import torch时用的却是另一个 Python。3.2 CSV 结构设计文本、图像路径和标签怎么对齐数据表我固定使用三列text、image_path、label。读进来后第一件事不是做特征而是做类型转换和空值检查import pandas as pd import os df pd.read_csv(train.csv) df[text] df[text].astype(str).fillna() df[image_path] df[image_path].astype(str) df[label] df[label].astype(int) df df[df[image_path].apply(lambda p: os.path.exists(p))].reset_index(dropTrue) df df[df[text].str.strip() ! ].reset_index(dropTrue) print(df[label].value_counts())astype(str)是 Python 类型转换里最容易被忽略的一步CSV 里某个字段可能是 NaN、int 或 float直接传给 tokenizer 会在encode阶段报错。fillna()再把空值统一成空字符串。过滤不存在的图像路径和空文本后还要再检查 label 分布因为多模态模型对类别不平衡比单模态更敏感后面要在 loss 里显式处理。3.3 图像 transform 与 BERT tokenizer 的参数设置文本侧用BertTokenizer图像侧用torchvision.transforms两者的超参数直接决定模型的输入分布from transformers import BertTokenizer from torchvision import transforms tokenizer BertTokenizer.from_pretrained(bert-base-uncased) 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]) ]) def encode_text_batch(texts, max_len64): return tokenizer( texts, max_lengthmax_len, paddingmax_length, truncationTrue, return_tensorspt, )参数按下面取值Resize((224, 224))是 ResNet 预训练模型的输入要求改成 256 再中心裁剪会多一点平移不变性但数据量小的时候往往直接 resize 更稳图像归一化的均值和方差来自 ImageNet因为用的是 resnet 预训练模型不能随便改成 0.5。max_length64对短文本情感评论足够超过 64 会被截断paddingmax_length让一个 batch 内所有样本长度完全一致省去后续动态 padding 的复杂度。return_tensorspt直接输出 PyTorch Tensor避免在 Dataset 里再转一次。一个容易忽略的点是不要在__getitem__里反复调用 PILImage.open去读大图也不要在这里做随机裁剪。更合理的做法是在数据准备阶段把图片统一缩到短边 256训练时再用RandomResizedCrop((224, 224))做增强这样 CPU 解码压力会小很多。4. 搭建 BERTResNet 双塔模型核心代码、参数设置与显存策略现在把融合层接回真正的双塔。这里最关键的改动是视觉塔不再输出 2048 维向量而是输出[B, 2048, 7, 7]特征图展平成[B, 49, 2048]文本塔保留序列[B, T, 768]。两路投影到 256 维后三种融合方法都可以在序列粒度上工作。4.1 双塔模型定义BERT 与 ResNet 预训练模型怎么接融合层下面这个类用method切换三种融合方式freeze_bert控制文本塔是否冻结import torch import torch.nn as nn from transformers import BertModel from torchvision.models import resnet50, ResNet50_Weights class BERTResNetFusion(nn.Module): def __init__(self, methodcross_attn, freeze_bertTrue): super().__init__() self.text_encoder BertModel.from_pretrained(bert-base-uncased) base resnet50(weightsResNet50_Weights.IMAGENET1K_V1) # 去掉 avgpool 和 fc保留 [B, 2048, 7, 7] 特征图 self.vision_encoder nn.Sequential(*list(base.children())[:-2]) self.text_proj nn.Linear(768, 256) self.vision_proj nn.Linear(2048, 256) self.dropout nn.Dropout(0.1) self.method method if method concat: self.neck nn.Linear(256 * 2, 256) elif method gated: self.gate nn.Linear(256, 256) elif method cross_attn: self.attn nn.MultiheadAttention( embed_dim256, num_heads4, batch_firstTrue ) self.classifier nn.Linear(256, 1) if freeze_bert: for p in self.text_encoder.parameters(): p.requires_grad False def forward(self, input_ids, attention_mask, images): # 文本特征保留序列供 cross_attn 使用 text_feat self.text_encoder( input_idsinput_ids, attention_maskattention_mask ).last_hidden_state text_feat self.text_proj(text_feat) # [B, T, 256] # 视觉特征图展平成 49 个空间 token vis_feat self.vision_encoder(images) # [B, 2048, 7, 7] vis_feat vis_feat.flatten(2).transpose(1, 2) # [B, 49, 2048] vis_feat self.vision_proj(vis_feat) # [B, 49, 256] if self.method concat: t text_feat.mean(dim1) v vis_feat.mean(dim1) h torch.cat([t, v], dim-1) h torch.relu(self.neck(h)) elif self.method gated: t text_feat.mean(dim1) v vis_feat.mean(dim1) g torch.sigmoid(self.gate(t)) h g * t (1 - g) * v elif self.method cross_attn: # 文本 token 作为 query图像 49 个位置作为 key/value h, _ self.attn( querytext_feat, keyvis_feat, valuevis_feat ) h h.mean(dim1) h self.dropout(h) return self.classifier(h).squeeze(1)vision_encoder nn.Sequential(*list(base.children())[:-2])去掉的是avgpool和fc所以前向输出保持空间结构。flatten(2)把[B, 2048, 7, 7]变成[B, 2048, 49]transpose(1, 2)变成[B, 49, 2048]也就是把图像切成了 49 个 token。concat 和 gated 分支先用 mean pooling 把序列压成向量cross_attn 分支直接在文本 token 和图像 token 之间做注意力最后再池化。这样写的好处是三个方法共享同一个双塔编码器后面做消融时只需要换method参数。4.2 优化器、学习率与类别不平衡处理情感分析里正负样本经常不平衡。多模态模型因为参数量大对不平衡更敏感我用BCEWithLogitsLoss的pos_weight来加权权重取负样本数除以正样本数from torch.optim import AdamW from transformers import get_linear_schedule_with_warmup model BERTResNetFusion(methodcross_attn, freeze_bertTrue).cuda() no_decay [bias, LayerNorm.weight] optimizer_grouped_parameters [ {params: [p for n, p in model.named_parameters() if p.requires_grad and not any(nd in n for nd in no_decay)], weight_decay: 0.01}, {params: [p for n, p in model.named_parameters() if p.requires_grad and any(nd in n for nd in no_decay)], weight_decay: 0.0}, ] optimizer AdamW(optimizer_grouped_parameters, lr2e-5) total_steps len(train_loader) * epochs scheduler get_linear_schedule_with_warmup( optimizer, num_warmup_stepsint(0.1 * total_steps), num_training_stepstotal_steps ) negative (df[label] 0).sum() positive (df[label] 1).sum() criterion nn.BCEWithLogitsLoss( pos_weighttorch.tensor([negative / positive]).cuda() )参数配置上我按下面这张表起步参数值理由BERT 学习率2e-5微调预训练模型的标准量级ResNet 学习率2e-5双塔一起训练时先用小步长走通了再调到 1e-4weight_decay0.01只对 bias 和 LayerNorm 之外的权重生效batch_size16双塔显存占用高不够时配合梯度累积warmup 比例10%避免预训练模型被大步长冲乱freeze_bertTrue时真正参与训练的只有视觉塔、投影层和分类层优化器里不会出现 BERT 权重。如果你放开 BERT 微调需要把 BERT 参数单独设一组lr1e-5ResNet 设lr1e-4否则几个 decoder layer 同时更新很容易在第三个 epoch 后过拟合。4.3 显存不足时的处理梯度累积、混合精度与冻结策略BERT 加 ResNet 的显存开销很大最常用的组合是梯度累积加混合精度。PyTorch 1.10 以后可以用torch.autocastmodel.text_encoder.gradient_checkpointing_enable() scaler torch.cuda.amp.GradScaler() for step, batch in enumerate(train_loader): input_ids batch[input_ids].cuda() attention_mask batch[attention_mask].cuda() images batch[image].cuda() labels batch[label].float().cuda() with torch.autocast(cuda, dtypetorch.float16): logits model(input_ids, attention_mask, images) loss criterion(logits, labels) scaler.scale(loss).backward() if (step 1) % accumulation_steps 0: scaler.step(optimizer) scaler.update() optimizer.zero_grad() scheduler.step()accumulation_steps2相当于把有效 batch 翻倍但对 BatchNorm 层不完全是同一个效果。如果 ResNet 也参与了训练尽量别用太大的梯度累积因为 BN 统计量在小 batch 下会有偏差。如果显存仍然不够就把freeze_bert保持为True再考虑把 ResNet 的layer4之前全部冻结只训练layer4和融合层。gradient_checkpointing_enable只对 BERT 的 transformer 层生效用一部分计算换显存训练时间大约增加两到三成。5. 用消融实验验证多种融合方法的真实收益多模态融合最容易踩的坑是融合模型的准确率看着高但实际只靠了文本或只靠了图像。要验证“多种融合方法”是否成立必须做剔除实验单模态文本、单模态图像、三种融合各跑一遍比较 F1 和准确率。5.1 用命令行参数控制融合模式给训练脚本加一个--method参数把模型构造和塔的开关绑在一起for method in text_only image_only concat gated cross_attn do python train.py --method $method --seed 42 --epochs 5 \ --freeze_bert true logs/${method}.log 21 done grep -H best_f1 logs/*.logtext_only和image_only不是融合但必须跑它们给出的是单模态上界。跑的时候固定--seed 42并且每隔相同步数在验证集上算一次f1否则收敛速度和最终分数都没法比。freeze_berttrue可以先把融合层和 ResNet 调通再放开 BERT 做微调。5.2 判断融合是否有效的三条标准第一融合后的 F1 要明显超过两个单模态中较好的那个。如果只提升了 0.1 个百分点多半是随机波动不算有效融合。第二看混淆矩阵的类别互补性文本模型容易把反讽判成正向图像模型容易把空荡的室内判成负向融合模型如果在这类样本上修正了才算真正利用了两个模态。第三打印cross_attn的权重文本里的“赞”和“空”应该分别 attend 到图像中的不同区域如果注意力权重接近均匀分布说明模型学到的是捷径而不是跨模态对应关系。一个可以保留到最后的技巧把验证集分成“文本可判”“图像可判”“必须融合”三部分分别打印融合模型的 F1。如果“必须融合”部分提升明显这个多模态方案才算站稳了。所以先跑单模态基线再跑融合别直接相信融合后的数字。本文还有配套的精品资源点击获取
返回列表