拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

多模态情感分析实战:BERT+ResNet特征融合与跨模态注意力详解

多模态情感分析实战:BERT+ResNet特征融合与跨模态注意力详解 简介面向高校人工智能课程设计与期末大作业场景这套Python多模态情感分析项目以BERTResNet为骨干基于Hugging Face与torchvision搭建在Models目录中提供Naive拼接、Naive加和及三种注意力融合共5种可对比方案适合需要完整工程模板和融合思路参考的本科/研究生开发者。压缩包共39个文件核心为17个Python源码文件、12个编译缓存pyc、3个文本说明、2个JSON标注数据及3张结构图整体约446KB另含README与requirements目录规划清晰可直接定位Config、Trainer、main及data等模块。资源已吸引1169人学习附带的train/test数据集便于快速跑通训练与评测。从跨模态注意力模型到消融对比读者可同时获得可运行源码、数据预处理工具、模型结构示意和项目文档能够显著缩短复现与二次开发时间便于在此基础上扩展新的融合策略与消融实验适合作为高分课设、竞赛基线或科研入门参考。1. 多模态情感分析到底在做什么BERTResNet这条路线为什么值得选给一张带文字描述的图片判断整体情绪是正、负还是中性这就是多模态情感分析要解决的问题。课设和作业里最常见的技术路线是BERT提取文本语义、ResNet提取图像语义再把两路特征做融合。但这条路的难点不在模型选择而在融合文本特征是离散符号编码出来的图像特征是像素空间卷积出来的两者分布完全不同直接拼接很容易让分类器被数值大的模态带偏最终分数还不如单模态模型。这篇笔记按数据预处理、主干特征抽取、多种融合方法、排错避坑逐层展开最后落到消融实验和可视化做法可以直接写进课设文档说明里。适合正在赶高分大作业的学生也适合刚接触图文多模态分类的Python开发者。把工程拆细、把融合结构做明白你的模型和报告才都能拿得出手。2. 数据准备把图文对变成BERT和ResNet的标准化输入2.1 数据集怎么组织从原始图文对到Dataset类多模态情感数据集一般有三种组织方式公开数据集如MVSA老师给的固定图文压缩包或者自己从社交媒体采集的图文对。无论哪种来源清洗后的核心字段都是image_id、text、label图片单独放一个目录。常见做法是先读成DataFrame再逐行检查文本为空就丢、图片路径不存在就丢、标签不是整数就丢。这一步不做干净后面训练到一半会莫名其妙炸掉。在Python工程里我一般会先用一个Dataset类把数据清洗和样本加载放在一起训练前一次性过滤掉坏样本。下面是一个可以直接抄的版本import os import pandas as pd from PIL import Image from torch.utils.data import Dataset class MultimodalDataset(Dataset): def __init__(self, csv_path, img_dir, tokenizer, transform): df pd.read_csv(csv_path, sep\t) self.samples [] for _, row in df.iterrows(): text str(row[text]).strip() img_path os.path.join(img_dir, f{row[image_id]}.jpg) if len(text) 0 or text nan: continue if not os.path.exists(img_path): continue try: label int(row[label]) except (ValueError, TypeError): continue self.samples.append((text, img_path, label)) self.tokenizer tokenizer self.transform transform def __len__(self): return len(self.samples) def __getitem__(self, idx): text, img_path, label self.samples[idx] text_tokens self.tokenizer( text, max_length64, truncationTrue, paddingmax_length, return_tensorspt ) text_tokens {k: v.squeeze(0) for k, v in text_tokens.items()} image Image.open(img_path).convert(RGB) image self.transform(image) return text_tokens, image, torch.tensor(label, dtypetorch.long)这段代码做了三件关键的事文本统一截断或补齐到64个token、图片统一走transform流程、标签转成torch.long。max_length64是我处理情感短文本的默认值因为评论和推文一般就一两句话超过64个词的情况很少设太大只会白白增加显存消耗。如果你的语料是长评论可以先统计一下文本长度分布把95%分位数当max_length。图片用convert(RGB)是防止数据集里混入灰度图或带透明通道的PNG不转的话到训练时通道数不匹配这个问题经常被人忽略。提示paddingmax_length必须保留。DataLoader默认的collate机制要求batch里所有文本长度一致如果不paddingtensor维度不一致会直接报错。2.2 文本分支的预处理BERT分词细节BERT的分词器用的是WordPiece底层会把一个词切成更小的subword片段。比如“unhappy”会变成“un”和“happy”“loving”会变成“lov”和“##ing”。所以文本预处理不能简单用split()按空格切词必须用transformers库里的BertTokenizer。from transformers import BertTokenizer tokenizer BertTokenizer.from_pretrained(bert-base-uncased) text The service here is not good, but the food is delicious. tokens tokenizer.tokenize(text) ids tokenizer.encode(text, add_special_tokensTrue) print(tokens) print(ids)输出结果里能看到开头有[CLS]、结尾有[SEP]中间是subword片段。中英文数据要选不同预训练模型英文用bert-base-uncased中文用bert-base-chinese不可以混用。uncased会把所有字母转小写对情感分析这种任务影响不大而且小写词表更小、训练更稳。协处理器方面我习惯直接用tokenizer它内部已经处理好了attention_mask不需要额外写分词逻辑。这里有个容易被忽略的点BERT预训练时输入上限是512个token但多模态任务里文本和图像要共享显存文本截断到64或128是常规操作。截断策略默认从尾部裁掉超出的部分对情感分析够用因为关键的情感词往往出现在句首和句末附近。2.3 图像分支的预处理ResNet的transform细节ResNet在ImageNet上预训练时输入是3通道224×224的图片并使用了ImageNet数据集的均值和标准差做归一化。所以使用torchvision的transform时这两项不能省参数也几乎固定from torchvision import transforms img_transform_train transforms.Compose([ transforms.Resize((256, 256)), transforms.RandomHorizontalFlip(p0.5), transforms.RandomCrop(224), transforms.ColorJitter(brightness0.2, contrast0.2), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) img_transform_val transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ])训练集用Resize(256)RandomCrop(224)而不是直接Resize(224)这样每次随机裁剪一小块区域相当于给模型提供了平移扰动比直接缩放的数据增强效果更好是ImageNet训练时的标准做法。验证集和测试集不能用RandomCrop统一用Resize(224)即可保证同一张图片每次评估得到相同结果。ColorJitter是给图像分支加颜色扰动防止模型过拟合颜色信息。如果你发现图像量很少可以再加RandomRotation(10)但注意情感分析里图片旋转过多会影响构图语义旋转角度不要加大。normalize的mean和std是ImageNet统计出来的固定值不是自己算的。很多人在这里翻车把std写错或者漏了这一步ResNet预训练权重就会“水土不服”Loss下降极慢。因为预训练模型的卷积核是按归一化后的数值分布学出来的输入分布不对等于白用预训练权重。3. 特征抽取BERT句向量与ResNet图像向量怎么对齐3.1 BERT编码器CLS向量还是平均池化BERT输出的last_hidden_state形状是[B, seq_len, 768]每个位置对应一个token的隐状态。最常见做法是取第一个token即[CLS]位置的向量作为整个句子的语义表示因为预训练时[CLS]就是用来聚合句子信息的。也可以做平均池化把所有token的向量取均值两种做法在情感分析短文本上效果接近。我自己更倾向CLS向量每个token的向量是离散符号经过自注意力层层融合得到的而CLS在上层还额外承接了“句子级语义”的角色信息更集中。不过BERT每一层的自注意力输出保留了不同细粒度的语义信息浅层偏词汇深层偏句子级。只用最后一层CLS是粗粒度特征把最后四层CLS向量拼接就得到3072维的细粒度特征。多模态融合里这种细粒度特征往往和图像细粒度特征更好匹配。from transformers import BertModel import torch.nn as nn class BertEncoder(nn.Module): def __init__(self, model_namebert-base-uncased, use_multi_layerFalse): super().__init__() self.bert BertModel.from_pretrained(model_name) self.use_multi_layer use_multi_layer def forward(self, input_ids, attention_mask): if not self.use_multi_layer: outputs self.bert(input_idsinput_ids, attention_maskattention_mask) return outputs.last_hidden_state[:, 0] # [B, 768] outputs self.bert( input_idsinput_ids, attention_maskattention_mask, output_hidden_statesTrue ) hidden outputs.hidden_states[-4:] # 最后4层 feat torch.cat([h[:, 0] for h in hidden], dim-1) # [B, 3072] return featuse_multi_layerTrue时输出维度从768变成3072后面的融合层要相应调整输入维度。预训练参数文件比较大建议先把bert-base-uncased权重下载到本地目录再用from_pretrained(本地路径, local_files_onlyTrue)加载方便离线复现。output_hidden_statesTrue会多返回每一层的隐状态训练时显存占用会明显上升如果显存吃紧就不要开多层拼接。3.2 ResNet编码器去掉分类头留下空间特征ResNet50默认结构最后是一个avgpool加一个1000类的全连接层。做特征抽取时要去掉fc层把输出变成[B, 2048]的全局特征。还有一种是保留layer4输出的空间特征图[B, 2048, 7, 7]这7×7的每个位置对应原图不同区域是图像的细粒度特征。两种输出对应两种融合策略下面代码把两种都实现出来import torchvision.models as models import torch.nn as nn class ResNetEncoder(nn.Module): def __init__(self, pretrainedTrue, keep_spatialFalse): super().__init__() self.backbone models.resnet50(pretrainedpretrained) self.backbone.fc nn.Identity() self.keep_spatial keep_spatial def forward(self, x): if not self.keep_spatial: return self.backbone(x) # [B, 2048] # 手动过主干保留空间维度 x self.backbone.conv1(x) x self.backbone.bn1(x) x self.backbone.relu(x) x self.backbone.maxpool(x) x self.backbone.layer1(x) x self.backbone.layer2(x) x self.backbone.layer3(x) x self.backbone.layer4(x) return x # [B, 2048, 7, 7]注意把avgpool设成Identity再调backbone(x)是不可行的因为后续fc接收的是展平后的向量等于把所有空间位置硬拼成一个204877的向量维度混乱。所以要手动逐层过主干网络保留7×7的空间布局这样后面才能做基于空间位置的跨模态注意力。ResNet不同的layer对应不同层级的语义layer2偏纹理layer3偏部件layer4偏全局语义这其实有点像小型FPN结构。做细粒度融合时可以把layer3和layer4的输出都取出来分别投影到同一维度再拼接模型就能同时看到粗粒度和细粒度的图像信息。不过这样会带来额外的显存开销课设里通常只用layer4就够了。3.3 维度对齐把768和2048统一到同一个空间文本向量768维图像向量2048维直接拼接会让分类器把更多注意力放在图像分支上因为图像分支的维度更大、数值尺度也可能更大。更稳的做法是把两个模态都投影到同一个较低维度比如256维再做融合。import torch.nn as nn class ModalProjection(nn.Module): def __init__(self, in_dim, out_dim256): super().__init__() self.proj nn.Sequential( nn.Linear(in_dim, out_dim), nn.ReLU(inplaceTrue), nn.LayerNorm(out_dim) ) def forward(self, x): return self.proj(x)参数说明out_dim选256是因为这个维度足够容纳语义信息又不会让后续融合层参数过多。LayerNorm的作用是让文本和图像两个模态的特征值尺度对齐避免某个模态的数值范围特别大直接把另一个模态淹没。如果out_dim选128速度快但信息损失偏大选512表达力更强但显存占用和过拟合风险都上升。先按256起步通过实验再调。投影层放在Encoder和Fusion之间训练时梯度会同时回传到两个分支。这里有一个细节值得说投影层不要用Dropout因为ReLULayerNorm已经提供了非线性正则再加Dropout会让短小的情感文本信息丢失过多。4. 多种融合方法对比从拼接、门控到跨模态注意力4.1 简单拼接最省力的基线最简单也最保险的融合方法是把文本特征和图像特征直接拼起来过一层MLP分类。这个方案可以作为baseline用来验证数据管道和训练流程是否正常。代码很短import torch import torch.nn as nn class ConcatFusion(nn.Module): def __init__(self, text_dim768, img_dim2048, num_classes3): super().__init__() self.clf nn.Sequential( nn.Linear(text_dim img_dim, 512), nn.ReLU(), nn.Dropout(0.3), nn.Linear(512, num_classes) ) def forward(self, text_feat, img_feat): feat torch.cat([text_feat, img_feat], dim-1) return self.clf(feat)如果加上了3.3里的投影层text_feat和img_feat就都是256维输入变成512维。直接拼接的问题在于两个模态是“并列”而非“交互”的关系分类器只能简单判断哪个模态的特征更显著无法建立“图片里的某个区域说明了文字里某个观点”这种对应。做课设的话这个方案能拿一个及格分但离“高分”还有距离。它的价值是当基线先用它把训练流程调通再替换成后面的融合方法。很多人一上来就搭注意力模型出了问题反而分不清是数据问题、优化问题还是融合模块的问题。4.2 门控融合让模型自己控制每个模态的信息量门控融合的思路是用两个神经网络分别计算文本和图像特征的权重权重范围在0到1之间按元素乘到原始特征上再做拼接或相加。门控的效果相当于模型根据当前样本自动决定“这条样本主要看文字还是主要看图片”。class GateFusion(nn.Module): def __init__(self, text_dim768, img_dim2048, num_classes3): super().__init__() self.gate_text nn.Sequential( nn.Linear(text_dim img_dim, text_dim), nn.Sigmoid() ) self.gate_img nn.Sequential( nn.Linear(text_dim img_dim, img_dim), nn.Sigmoid() ) self.clf nn.Sequential( nn.Linear(text_dim img_dim, 512), nn.ReLU(), nn.Dropout(0.3), nn.Linear(512, num_classes) ) def forward(self, text_feat, img_feat): gate_input torch.cat([text_feat, img_feat], dim-1) g_t self.gate_text(gate_input) # [B, text_dim] g_i self.gate_img(gate_input) # [B, img_dim] fused torch.cat([g_t * text_feat, g_i * img_feat], dim-1) return self.clf(fused)门控可以看作注意力机制的最简形式两个模态通过交互输入各自生成一个标量向量决定每个特征维度的通过程度。Sigmoid激活函数输出0到1不会像Softmax那样产生竞争关系因为文本和图像可以同时被保留也可以同时被抑制。训练初期门控的输入分布不稳定我建议把门控网络的最后一层bias初始化为0这样初始权重接近0.5不会一上来就把某个模态整个关掉。这个方案非常适合课设代码量不大报告里能写清楚“模型根据什么决定看哪个模态”而且训练速度比注意力方法快不少。实测中GateFusion比ConcatFusion通常高1到2个点的准确率但还不够触及上限。4.3 跨模态注意力让文本和图像的特征相互寻找关键线索跨模态注意力是这几种融合方法里上限最高的方案也是大作业拿高分的关键。核心思想是用BERT里那套自注意力机制但让Q来自文本、K和V来自图像这样文本每个token都会去图像特征图里寻找和自己最相关的空间区域最终加权汇总图像信息。先用3.2里保留空间特征的ResNet输出[B, 2048, 7, 7]把它展平成[B, 49, 2048]当成图像序列文本侧用BERT最后一层所有token的输出[B, seq_len, 768]。两个序列长度不一致没关系先各自投影到256维然后过跨模态注意力模块。import math import torch import torch.nn as nn class CrossAttentionBlock(nn.Module): def __init__(self, hidden256, num_heads4): super().__init__() self.attn nn.MultiheadAttention( embed_dimhidden, num_headsnum_heads, batch_firstTrue ) self.norm nn.LayerNorm(hidden) def forward(self, query, kv): # query: [B, Lq, H] 文本序列 # kv: [B, Lk, H] 图像空间特征展开序列 attn_out, attn_weights self.attn(query, kv, kv) return self.norm(query attn_out), attn_weights这里的batch_firstTrue很关键PyTorch的MultiheadAttention默认是batch第二维忘设这个参数会导致维度错乱。残差连接加LayerNorm是标准Transformer block里的结构防止深层堆叠时梯度消失。attn_weights的形状是[B, Lq, Lk]代表每个文本token对49个图像区域的注意力权重这个矩阵保存下来后面可视化就是靠它。还有一个细节不能少图像特征展开成序列后49个位置是有空间顺序的但注意力本身对位置不敏感所以要在展开后的图像特征上加位置编码。pos_embed nn.Parameter(torch.randn(1, 49, hidden)) # 可学习的位置编码 kv kv pos_embed我习惯在投影之后、进入CrossAttention之前加位置编码。位置编码可以让模型知道图像上方的区域和下方的区域是不同的语义位置对情感分析这种构图信息敏感的任务帮助明显。如果想进一步提点可以堆叠两个CrossAttentionBlock第一个让文本注意图像第二个让图像注意文本两个方向都做完再送入分类器。这种双向交叉注意力就是论文里常见的CoAttention结构。跨模态注意力方案的计算开销集中在49×Lq的注意力矩阵上Lq64时矩阵大小是64×49完全在可控范围内。训练时建议batch size从8开始watch显存曲线有余量再往上加。4.4 三种融合方法怎么选融合方法特征维度参数量表达能力可解释性显存占用适用场景Concat投影后512低弱无低快速跑通流程的baselineGate投影后512中中中低效果与训练速度的平衡点CrossAttention投影后256高强强高大作业/课设冲高分的核心模块选型建议很简单时间紧就直接Concat或Gate把剩余精力投到数据清洗和调参上目标是高分就上CrossAttention因为它能解决两个关键问题——图像特征没有对齐文本关键语义、文本没有明确的证据去支撑分类结果。报告里有了注意力权重可视化老师能一眼看到你做了多模态对齐的工作分数自然不一样。5. 避坑多模态训练里最容易翻车的几件事5.1 训练Loss下降了验证分数却不动现象训练集loss稳定下降但验证集准确率和F1都原地踏步预测结果几乎全是数量最多的那个类别。原因多模态情感数据集普遍存在类别不平衡很多公开数据集里“中性”样本占比超过50%。模型发现只要全预测“中性”就能把loss压得很低根本不需要从图片和文字里学特征。解决给CrossEntropyLoss加class_weight或者用重采样让三个类别数量接近。import torch.nn as nn loss_fn nn.CrossEntropyLoss( weighttorch.tensor([0.8, 1.2, 0.8]).cuda() )weight按类别样本数的倒数归一化设置这样少数类样本的loss贡献权重更大。同时评估指标不要只看准确率加一个宏平均F1。如果做了weight还是不行检查一下是不是验证集和训练集的标签分布差异太大。5.2 BERT和ResNet用同一个学习率效果奇差现象训练过程不收敛或者收敛后验证分数还不如单模态模型。原因BERT和ResNet都是预训练模型但它们的参数分布和微调敏感度完全不同。BERT的顶层需要相对小的学习率来保持预训练语义而ResNet的分类层替换成全连接层后新层需要相对大的学习率来快速适配。用一个统一的3e-5BERT可能欠拟合ResNet的分类层又学不动。解决给不同模块设置不同学习率常见做法是BERT用1e-5到2e-5ResNet用1e-4新增的分类头和融合层用1e-3。optimizer torch.optim.AdamW([ {params: bert_encoder.parameters(), lr: 1e-5}, {params: resnet_encoder.parameters(), lr: 1e-4}, {params: fusion_module.parameters(), lr: 1e-3}, ])这是多模态微调里性价比最高的一个调参手段很多课设分数卡在70附近调完这组学习率直接涨3到5个点。注意融合层如果包含多层Transformer结构学习率也不要太大1e-3已经偏高可以从1e-4起步。5.3 显存不够batch size只能设成4现象一张12G显卡BERT加ResNet跑起来直接OOM被迫把batch调到4结果训练慢且不稳定。原因BERT的最大序列长度和ResNet的输入分辨率共同决定了显存占用。64个token加224×224的3通道图单个样本特征图其实没多大但BERT的hidden states、ResNet的中间特征图都保留在显存里供反向传播使用。解决三条路同时走。第一文本max_length从128降到64显存接近减半第二开启Gradient Checkpointing第三用梯度累积batch设成8每两步累积一次梯度等效batch为16。last_hidden self.bert( input_idsinput_ids, attention_maskattention_mask, output_hidden_statesFalse, use_cacheFalse )use_cacheFalse是为推理设计的位置索引缓存训练时开着纯属浪费显存。output_hidden_states只有在需要多层特征拼接时才打开。训练里用torch.cuda.amp.autocast混合精度是最简单且收益最大的显存优化手段不需要改模型结构。5.4 图像数据增强过度把文本语义也带偏了现象训练集准确率很高验证集准确率波动大某些样本明显应该分对却分错。原因图像增强太狠。RandomCrop把主体裁掉一半ColorJitter把亮度调得过暗图片里的情感线索已经变了但文本还在说“这地方真棒”两个模态提供矛盾信息模型学到的关联被破坏。解决降低增强强度尤其是RandomCrop。Resize(256)RandomCrop(224)是从256×256里裁剪224区域如果原图本身就比较紧凑裁完可能丢失关键物体。换成Resize(224)RandomHorizontalFlip即可保留ColorJitter但把亮度对比度调低到0.1。验证集绝不使用RandomCrop和HorizontalFlip。还有一个技巧做数据清洗时把图像主体过于偏离中心、明显是裁剪错误的样本筛掉比增强效果更直接。5.5 训练时效果好保存模型后推理结果对不上现象训练和验证都在线上跑得很好把模型保存下来做单张图片推理结果却异常甚至分类到无关类别。原因三处不一致导致的。一是推理时transform没有用验证集那套而是重新写了一段可能忘了Normalize。二是tokenizer加载了不同版本分出来的subword不一样。三是模型保存时只存了参数没有保存tokenizer和transform配置。解决把预处理封装成一个函数训练、验证、推理三处都调用同一个方法保存时用model.save_pretrained保存模型权重同时把tokenizer也保存下来。tokenizer.save_pretrained(./checkpoint) model.bert.save_pretrained(./checkpoint) tokenizer BertTokenizer.from_pretrained(./checkpoint)推理脚本里第一件事就是从checkpoint目录加载tokenizer而不是从huggingface仓库重新加载。transform的Normalize参数如果丢失图像数值范围还是0到1但预训练权重期望的是按ImageNet统计量归一化后的分布差一个量级结果完全不可用。这一步检查一遍推理基本不会出差错。6. 把文档和分数做实消融、可视化与参数表6.1 消融实验这样设计报告里才有说服力拿到CrossAttention模型后不要只放一个最终准确率。设计一组消融实验单文本BERT、单图像ResNet、Concat、Gate、CrossAttention五组配置每组固定随机种子跑三遍取平均记录准确率和宏平均F1。这个消融表的含金量很高能证明融合方法确实有效也能让老师看到你对“多种融合方法”的理解不是停留在概念层面。消融实验的代码组织建议把融合模块的切换做成一个开关参数比如fusion_typeconcat、gate、cross训练脚本里直接用字符串指定不要维护五份重复代码。6.2 注意力可视化给报告加分CrossAttention训练完成后拿几个验证集样本把attn_weights保存下来用matplotlib画成热力图把图像原图、文本token、注意力权重对齐展示。方法是训练时多返回一个attn_weights变量attn_map torch.mean(attn_weights, dim1) # 多头取均值[B, Lk] attn_map attn_map[0].detach().cpu().numpy() # 取第一个样本 np.save(attn_sample.npy, attn_map)画图时把49个权重reshape成7×7resize到224×224叠加到原图上就能清楚看到文本中“angry”这个词对应到图片里人物的表情区域。这种图放进课设文档比写一千字描述都管用。6.3 参数表怎么填文档说明才会显得完整最后在文档里放一张表模型配置、文本长度、图像分辨率、学习率、batch size、epoch、参数量、准确率、宏平均F1。把每个融合方法对应的实验结果都列进去。再把前面避坑章节里遇到的最典型问题写成一节“遇到的问题与解决”比如类别不平衡怎么处理的、显存不够怎么调的、学习率怎么分层的。这些内容才是“高分大作业”的真正来源。老师看重的不是最终跑到了多少分而是你能不能把黑匣子打开说清楚每一步为什么这么选、失败时从哪里排查。我做这门课设时最深的教训就是一开始急着上CrossAttention结果数据、学习率、融合模块三个环节同时出问题排查了两天才定位到是学习率没分开。先跑通baseline再从简到繁替换融合结构这条路看起来慢实际上是最快的。希望帮到你。本文还有配套的精品资源点击获取
返回列表