BERT+ResNet多模态情感分析实战:从数据预处理到融合方法
简介面向人工智能课程设计与期末大作业基于BERTResNet的多模态情感分析项目提供了完整源码与文档说明涵盖两种朴素融合与三种注意力融合方法并配有网络结构示意图。项目依托Hugging Face与torchvision构建在Models文件夹中集中实现多种融合策略同时通过Config.py、Trainer.py及数据预处理模块将配置、训练、数据处理串联起来适合需要完成高分课设或系统学习图文情感分析的计算机专业学生参考。压缩包共39个文件以Python源码为主17个py另含12个pyc编译文件、3个txt说明、3个png结构图、2个json数据集及1个Markdown文档整包仅446KB轻量便携、目录清晰。已有1170人学习下载对于希望快速上手多模态情感分析并对比不同融合效果的开发者是一份可直接运行、便于扩展的实用资源。1. 为什么做多模态情感分析而不是单模态跑跑就完事纯文本情感分析有个很明显的盲区一句“你可真厉害”放在没有表情包的客服对话里是夸奖配上翻白眼自拍就变成讽刺。图像情感分析反过来只看图片猜情绪碰到“手里拿着刀笑”这种画面很难判断是搞怪还是危险。多模态情感分析要解决的就是这个问题——把文本语义和视觉表情同时交给模型让判断依据从单通道变成双通道。基于BERTResNet做这个任务是目前大作业和课程设计里性价比最高的方案BERT吃文本ResNet吃图像后端的融合方法有十几种可调改一处模块就能出一组对比实验写报告、画图表、答辩都有东西讲。适合想拿高分的 AI 方向学生也适合刚上手多模态应用的研发。下面按我实际做过的方案把从数据处理到融合实现、再到排坑的完整路径给你过一遍。2. 骨架选型BERT管文本、ResNet管图像以及融合该从哪里切入2.1 BERT做文本编码预训练模型只看特征别想着从头训BERT能成为文本骨架是因为它用 Transformer 的双向编码能力把上下文关系吃透了。拿情感分析来说是直接拿预训练权重做迁移学习比从零搭建一层 LSTM 节省大量时间。做法一般是加载预训练模型把文本通过 tokenizer 转成 input_ids 和 attention_mask前向传播得到 last_hidden_state再取序列第一个 token也就是 CLS对应的向量作为整句表示或者对最后一层做平均池化。具体代码里我会先用 Hugging Face 的 transformers 库加载模型。模型名用 bert-base-uncased 处理英文处理中文用 bert-base-chinese别混。输入文本先做归一化——去首尾空白把部分 URL 替换成特殊 token表情符号保留——再交给 tokenizer。实际操作中很多课设数据是抓来的微博或 Twitter 文本里面全是 用户、链接、emoji不做清理tokenizer 会把一句话劈成几十个碎块attention_mask越长推理越慢情感语义还被稀释。一个关键参数是max_length。文本长度超过max_length会被截断短于它会填充。常见经验值是 64 到 128情感分析不像阅读理解需要长距离记忆句子一般不超过 50 token取 64 够用。截断方式用truncationTrue填充用paddingmax_length保证 batch 内长度一致。注意别为省显存把max_length压到 32 以下句子被腰斩后转折词和否定词丢失模型会突然把“一点都不喜欢”判成正面。以下是文本编码的基础片段from transformers import BertTokenizer, BertModel tokenizer BertTokenizer.from_pretrained(bert-base-uncased) bert BertModel.from_pretrained(bert-base-uncased) def encode_text(text: str, max_len: int 64) - dict: # 先做归一化防止 用户 和 URL 干扰分词 text text.strip().replace(http\S, [URL]).replace(\w, [USER]) encoded tokenizer( text, max_lengthmax_len, paddingmax_length, truncationTrue, return_tensorspt ) return { input_ids: encoded[input_ids], # shape: [1, max_len] attention_mask: encoded[attention_mask] # 1表示真实token0表示填充 }这段代码里return_tensorspt让结果直接是 PyTorch 张量省一次转换。attention_mask一定要传入 BERT 前向方法否则填充位置还会参与注意力计算等于给模型注入噪声。我在第一次跑的时候忘了传 mask训练 loss 死活降不下去后来把 mask 加上一轮就明显收敛。BERT 输出的last_hidden_state的 shape 是[batch, seq_len, hidden_dim]BERT-base 的hidden_dim是 768。你要拼向量、做注意力这个维度要记牢。2.2 ResNet做图像编码预训练权重够用最后一层直接砍掉ResNet 的优势在残差结构能把网络堆深一点而不至于梯度消失。这个任务里不需要从零训练直接用 torchvision 的预训练权重。我一般用 ResNet50输出维度 2048比 ResNet18 的 512 维更能撑住多模态融合如果你数据量小、显卡又紧张先上 ResNet18后面在意指标再换 50。这里有个容易搞错的地方torchvision 里的resnet50(pretrainedTrue)会自带最后的fc分类层比如 ImageNet 的 1000 类我们要拿的是图像特征而不是类别概率所以把fc层替换成恒等映射或用model.avgpool之后的输出import torch.nn as nn from torchvision import models def build_resnet(version: str resnet50, freeze_layers: bool True): if version resnet50: model models.resnet50(weightsmodels.ResNet50_Weights.IMAGENET1K_V2) out_dim 2048 else: model models.resnet18(weightsmodels.ResNet18_Weights.IMAGENET1K_V1) out_dim 512 # 删掉最后的分类层保留全局平均池化的输出 model.fc nn.Identity() # 冻结前90%层只让最后一两个block参与微调 if freeze_layers: for param in model.parameters(): param.requires_grad False for param in model.layer4.parameters(): param.requires_grad True return model, out_dim一般来说冻结前几层的原因很简单浅层学的是边缘、颜色、纹理这种通用视觉特征用大数据集训好的权重在这些层上已经足够好微调反而会过拟合。最后把layer4解冻让网络针对图片里的脸部表情或物体上下文做少量适配。如果你数据量很大几千张以上且算力充裕可以不冻结完全微调代价是训练时间成倍增长。ResNet 输入前还要经过归一化这是后一章的内容。2.3 融合为什么难两个特征空间差了十万八千里BERT 输出的文本特征在语言空间ResNet 输出的图像特征在视觉空间。BERT-base 的特征向量是 768 维ResNet50 是 2048 维两者不仅尺度不同数据分布也不同。直接把两个向量torch.cat在一起全连接层会发现文本分支的梯度贡献远小于图像分支模型被图像特征带着跑。所以融合不是“拼一下就行”而是要想办法让两个模态在进入分类器前先互相“听懂”。这也是为什么标题里写的“多种融合方法”值得做——不同融合结构在同一个数据集上的指标差异恰恰是答辩时的亮点。融合方法大体能按信息交互的时机分三类早融合特征提取后立即拼接交互少晚融合先独立得到两个分类得分最后加权中间融合在特征提取过程中就互相穿插比如注意力。对课设来说把三种类型都实现一遍对比表能填图也能画。下一章先把数据处理好再逐个实现融合模块。3. 预处理与Dataset让一张图和一句文字对齐成一个样本3.1 数据表格与目录怎么组织做多模态情感分析最怕文件命名混乱。我见过的翻车例子文本 CSV 里写的是img1.jpg图片目录里却有IMG_1.JPG、img1(1).jpg大小写和空格导致读取失败。先把数据目录固定下来所有样本统一叫id.jpg文本用 id 关联。目录结构长这样dataset/ texts.csv # id,text,label images/ # id.jpgtexts.csv是主表label取 0、1、2 三分类负面、中性、正面或 0、1 二分类。读取时严格用str(id)做 key避免 int 与字符串错位。写 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, text_encoder, img_transform, max_len64): self.df pd.read_csv(csv_path) self.img_dir img_dir self.text_encoder text_encoder self.img_transform img_transform self.max_len max_len def __len__(self): return len(self.df) def __getitem__(self, idx): row self.df.iloc[idx] image Image.open(os.path.join(self.img_dir, f{row[id]}.jpg)).convert(RGB) image self.img_transform(image) text self.text_encoder(str(row[text]), max_lenself.max_len) label int(row[label]) return { image: image, input_ids: text[input_ids].squeeze(0), attention_mask: text[attention_mask].squeeze(0), label: torch.tensor(label, dtypetorch.long) }这里有个细节PIL 打开图片后统一.convert(RGB)因为灰度图或 RGBA 图会导致后面Normalize报通道数不匹配。text_encoder返回的是带 batch 维的张量Dataset 要squeeze(0)把 batch 维去掉否则后面DataLoader会多套一层 batch变成[batch, 1, seq_len]模型直接炸形状。这个坑我debug了半小时。3.2 文本编码的四个必调参数上一章里encode_text已经出现过实战中这四个参数每个都别省max_length、padding、truncation、return_tensors。其中paddingmax_length比paddingTrue更可控因为 true 模式是等一个 batch 里最长文本但 Dataset 是逐条返回DataLoader 的 collate 如果不做 padding 就会报错。所以统一在 tokenizer 阶段填充到max_lengthcollate_fn只要简单torch.stack即可。有的文本很短只有一两个字比如“哈哈”。这时 attention_mask 大部分是 0BERT 会输出一个几乎全是 padding position 的表示模型判断会退化。常见做法是把这类样本合并扩充比如“哈哈哈哈哈”或者直接用真实长度拼接原始文本。课设里不需要太多花活但max_length64时如果一个 batch 里大部分文本实际长度只有 5~10模型的语义容量是被浪费的。你可以统计text_len分布把 64 改成 48 或 32F1 可能会小幅上涨。3.3 图像预处理别直接复制ImageNet那套增广图像的预处理一般包含 Resize、CenterCrop、ToTensor、Normalize。这里最容易踩坑的是把训练用的随机裁剪、随机翻转直接搬到验证阶段。文本情感和图像情感有强对齐比如一张摔倒的照片水平翻转后可能被看作假摔随机裁剪如果把表情区域裁掉模型只能看到身体情感判断直接改变。所以我在验证集和测试集上只用Resize(256)CenterCrop(224)训练集才加 RandomResizedCrop 和较小的 RandomHorizontalFlip。归一化的 mean/std 必须和预训练权重的训练数据一致torchvision 的模型都用 ImageNet 的统计量from torchvision import transforms train_transform transforms.Compose([ transforms.Resize((256, 256)), transforms.RandomResizedCrop(224, scale(0.8, 1.0)), transforms.RandomHorizontalFlip(p0.3), transforms.ToTensor(), transforms.Normalize( mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225] ) ]) val_transform transforms.Compose([ transforms.Resize((256, 256)), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize( mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225] ) ])RandomResizedCrop加scale(0.8, 1.0)是限制裁剪范围别用默认(0.08, 1.0)那会把一张脸裁成一只耳朵。RandomHorizontalFlip对无方向性的图没问题但包含文字或左右朝向明显的图要慎用。还有如果你在 Python 环境里安装 torchvision 时卡在 numpy 版本冲突先卸载 numpy 再用官方 requirements 重装这种环境问题通常是 Python 3.8 配新版 torchvision 导致的血泪经验。4. 融合方法从简单到复杂拼接、门控、跨模态注意力和完整模型代码4.1 基线拼接先把维度对齐再谈融合效果最简单的融合是特征级拼接文本特征text_featshape[batch, 768]图像特征image_featshape[batch, 2048]直接在最后一维拼成[batch, 2816]。拼接后接一个全连接层降维到分类数。实战里发现直接接全连接层收敛慢最好先各过一个 BN 层再接拼接。class ConcatFusion(nn.Module): def __init__(self, text_dim768, image_dim2048, num_classes3): super().__init__() self.text_bn nn.BatchNorm1d(text_dim) self.image_bn nn.BatchNorm1d(image_dim) self.classifier nn.Sequential( nn.Linear(text_dim image_dim, 512), nn.ReLU(), nn.Dropout(0.3), nn.Linear(512, num_classes) ) def forward(self, text_feat, image_feat): text_feat self.text_bn(text_feat) image_feat self.image_bn(image_feat) fused torch.cat([text_feat, image_feat], dim-1) # [batch, 2816] return self.classifier(fused)这里BatchNorm1d的输入必须是二维如果你的 text_feat 是[batch, seq_len, hidden]那要先压缩成[batch, hidden]。前一章取 CLS 向量还是平均池化这一步就起作用了。我建议先取平均池化因为平均池化能保留整句语义CLS 向量更偏分类语义在多模态场景里平均池化不容易丢失转折信息。这个模块常作为“基线”出现在消融表里其他融合方法都要跟它比。4.2 门控融合让模型自己决定信文本还是信图像门控融合的思路是学习出一个权重向量控制两个模态各自保留多少信息再相加。用 sigmoid 把权重压到 (0, 1) 区间形成 soft gating。数学上类似g sigmoid(W_g * [text; image] b_g) fused g * text (1 - g) * image这里的g由两个模态共同决定实现了模态间的最浅层交互。实现时要注意门控的输入不要用归一化前的原始特征先过 BN 再算门否则梯度在 sigmoid 附近容易饱和。class GateFusion(nn.Module): def __init__(self, text_dim768, image_dim2048, num_classes3): super().__init__() self.project nn.Sequential( nn.Linear(text_dim image_dim, 512), nn.ReLU() ) self.gate nn.Sequential( nn.Linear(512, 1), nn.Sigmoid() ) self.classifier nn.Sequential( nn.Linear(text_dim, num_classes) # 门控后维度对齐到text_dim ) def forward(self, text_feat, image_feat): both torch.cat([text_feat, image_feat], dim-1) proj self.project(both) g self.gate(proj) # [batch, 1] # 门控的结果是插值要求维度一致所以先把image投影到text_dim image_proj nn.Linear(image_feat.shape[1], text_feat.shape[1]).to(image_feat.device)(image_feat) fused g * text_feat (1 - g) * image_proj return self.classifier(fused)上面代码里nn.Linear放在 forward 里不是好习惯每步都重建参数实践时应该把 image_proj 定义在__init__。这里为了展示维度匹配逻辑才临时投影。门控融合的优点是参数少、不容易过拟合尤其适合几百张图的小数据集。缺点是门控机制太过简单无法建模文本中某个词对应图像中某个部位这种细粒度关系。比它更强的是注意力融合。4.3 跨模态注意力让文本看到图像里的重点区域跨模态注意力本质是把文本特征作为查询Query图像特征作为键值Key/Value通过注意力矩阵动态分配图像各区域的权重。比如一句话说“这只猫很可爱”模型可以把注意力放在猫的脸上而不是背景的书桌上。实现前不要求你对 Transformer 掌握很深用torch.nn.MultiheadAttention就能搭import torch class CrossAttentionFusion(nn.Module): def __init__(self, text_dim768, image_dim2048, num_heads8, num_classes3): super().__init__() # 需要先把图像维度投影到和文本一致才能做注意力 self.image_proj nn.Linear(image_dim, text_dim) self.cross_attn nn.MultiheadAttention( embed_dimtext_dim, num_headsnum_heads, batch_firstTrue ) self.layer_norm nn.LayerNorm(text_dim) self.classifier nn.Sequential( nn.Linear(text_dim, 256), nn.ReLU(), nn.Dropout(0.2), nn.Linear(256, num_classes) ) def forward(self, text_feat, image_feat): # text_feat: [batch, text_dim] - 扩展成 [batch, 1, text_dim] text_feat text_feat.unsqueeze(1) image_feat self.image_proj(image_feat).unsqueeze(1) # [batch, 1, text_dim] attn_out, attn_weight self.cross_attn( querytext_feat, keyimage_feat, valueimage_feat ) # 残差连接 层归一化稳定训练 fused self.layer_norm(attn_out.squeeze(1) text_feat.squeeze(1)) return self.classifier(fused), attn_weightattn_weight就是模型对图像不同位置的注意力分布可以拿来做可视化。这里图像只有一个全局向量注意力退化成几乎无意义效果不如直接把两个特征做向量内积。更有效的做法是把图像特征映射成多个区域比如 ResNet 倒数第二层特征图是[batch, 2048, 7, 7]可以先池化成[batch, 2048, 7]或[batch, 2048, 49]作为 49 个 key。这样注意力就能知道“图像左上角那块区域更重要”。不过对课设来说用单向量版本足够跑通并解释原理。如果你想在答辩里多聊一句“细粒度”可以看我后面的进阶技巧把特征图展开成序列。这里给出的是可复现的简单实现。4.4 把三种融合集成在一个可运行模型里方便做对比与其写三个互相独立的 train.py不如把常见结构封装成一个类通过fusion_type切换。这样做实验时你只需改参数不用改训练循环也能避免因为代码不一致导致的误差比如漏了 Dropout、激活函数不对等。下面是一个整合版本的模型骨架import torch.nn as nn class MultimodalModel(nn.Module): def __init__(self, text_dim768, image_dim2048, num_classes3, fusionconcat): super().__init__() assert fusion in {concat, gate, attention} self.fusion fusion self.image_proj nn.Identity() # 根据fusion类型调整 if fusion concat: self.fusion_layer ConcatFusion(text_dim, image_dim, num_classes) elif fusion gate: self.fusion_layer GateFusion(text_dim, image_dim, num_classes) elif fusion attention: self.fusion_layer CrossAttentionFusion(text_dim, image_dim, num_classes) def forward(self, text_feat, image_feat): if self.fusion attention: logits, attn self.fusion_layer(text_feat, image_feat) return logits, attn return self.fusion_layer(text_feat, image_feat)训练代码只需要在初始化时传fusiongate就能依次跑三组实验。注意每个融合模块内部都已经包含了分类器所以整体模型里不需要再加一个最后的fc。这也是导致我最初模型参数膨胀、损失不降的一个原因——融合模块里有一个全连接外面又套一个多层全连接优化空间变得非常难搜。5. 训练调参与避坑损失、学习率、冻结策略和我翻过的四次车5.1 基础训练配置双区学习率比统一学习率更稳BERT 和 ResNet 的预训练程度不同学习率也应该分开设。BERT 迁移学习中一般给 1e-5 到 3e-5ResNet 微调层给 1e-4 到 3e-4。如果统一用 3e-4BERT 很容易被冲得忘掉预训练语义loss 一开始下降几个 epoch 后冲高。我用 AdamW 优化器weight_decay1e-2分别设置两组参数from transformers import AdamW def build_optimizer(model, bert_model, lr_bert1e-5, lr_rest1e-4): bert_params [] rest_params [] for name, param in model.named_parameters(): if not param.requires_grad: continue if bert in name: bert_params.append(param) else: rest_params.append(param) return AdamW([ {params: bert_params, lr: lr_bert}, {params: rest_params, lr: lr_rest} ], weight_decay1e-2)损失函数用交叉熵时如果数据集类别不均衡一定要在CrossEntropyLoss里加weight。我拿到的课设数据里“负面”样本比“正面”多两倍不处理的话模型把所有样本都判成负面准确率能到 70%F1 却不到 10%。权重取各类样本数的倒数再归一化一次性解决。Batch size 建议 16 或 32再大显存扛不住再小 BN 层统计不稳定容易振荡。5.2 梯度累积显卡不够时的后悔药如果你只有 4GB 显存batch size 设成 16 会直接 OOM。常见做法是梯度累积batch size 设 4累积 4 步再更新一次参数等效 batch 16。但有个坑BERT 的 Dropout 在累积时每步都在变化等效做了一些数据扰动效果未必差。实现时要手动调用loss.backward()步数达到累积次数才optimizer.step()accumulation_steps 4 for step, batch in enumerate(train_loader): loss compute_loss(batch) loss loss / accumulation_steps # 归一化避免梯度累积后量级变大 loss.backward() if (step 1) % accumulation_steps 0: optimizer.step() optimizer.zero_grad()loss / accumulation_steps这一行别忘不然等效 batch 变大后学习率没变收敛容易发飘。梯度裁剪clip_grad_norm_(1.0)也是防 BERT 训练后期梯度爆炸的通用做法。5.3 我踩过的四个坑你大概率躲不过第一个坑图像翻转导致情感标签反了。现象是训练 loss 下降很快但验证 F1 一直低。原因是验证集只做了 CenterCrop 没翻转训练集却用了RandomHorizontalFlip(p0.5)某些带有左右朝向语义的图片比如“左边是旧衣服右边是新衣服”的对比图翻转后情感极性变化。解决先看训练集和验证集翻转规则是否一致把翻转概率降到 0.1或干脆去掉。第二个坑BERT tokenizer 重复加载导致 CPU 内存暴涨。现象是跑两个 epoch 后机器卡死。原因是我在 Dataset 的__getitem__里每次都调用tokenizer.encode而encode内部加载词表文件进程一多直接把内存打满。解决在__init__里一次性初始化 tokenizer并把它作为参数传入 Dataset不要在每个样本里重新实例化。第三个坑ResNet 冻结后layer4的requires_grad确实设了 True却加载了model.fc nn.Identity()导致输出维度对不上。现象是最后一维从 2048 变成 1全连接层矩阵乘法直接报错。解决检查model.fc是否真的是Identity()如果不是用model.avgpool后的torch.flatten手动获取 2048 维向量。第四个坑多个融合模块都用Dropout(0.5)小数据集上训练损失和验证损失差距巨大。原因是很小的样本量配上高 Dropout 会让模型学不稳定。解决先设Dropout(0.3)如果训练损失比验证损失低一半说明过拟合再加早停或 L2如果验证损失不降降低 Dropout 到 0.2。5.4 训练收敛的判断不要只看准确率训练到一半loss 从 1.0 降到 0.3但验证准确率没变化别以为模型坏了。情感分类的类别不平衡时准确率是虚的重点是 F1。我习惯每两个 epoch 保存一张图画训练/验证 loss 和 macro-F1 曲线。调参时先固定融合方法只改学习率和 batch size记录三条曲线。改融合方法时保持同样的数据、随机种子、学习率否则对比不公平。下面是手工验证一个样本的代码def predict_one(model, text_feat, image_feat, class_names): model.eval() with torch.no_grad(): logits, *_ model(text_feat.unsqueeze(0), image_feat.unsqueeze(0)) pred logits.argmax(dim1).item() return class_names[pred]unsqueeze(0)是为了补 batch 维因为模型里 BatchNorm 和注意力都要求 batch 维存在。你可以随机挑 10 条数据把预测概率和文本、图片放在一起看比任何曲线都直观——比如模型把“有一点点不开心”判成消极说明它已经理解否定词了。6. 把实验做成高分课设评估指标、消融表和 Attention 可视化验证部分建议按三件事去做训练集/验证集按 8:2 划分随机种子固定成 42每个融合方法跑三遍取平均。报告里不要只给准确率给 macro-F1 和加权 F1特别是类别不平衡时macro-F1 才能反映模型对少数类是否友好。我习惯画一张表格行是三种融合方法列是 ACC、macro-F1、参数量、单 epoch 耗时。消融实验除了“只文本”“只图像”“拼接”“门控”“注意力”这几行再加一行“本文的双模态注意力”能体现增量贡献。Attention 可视化是展示多模态模型内部逻辑最直观的技巧。前面CrossAttentionFusion返回了attn_weight你可以把图片切分成 7×7 的格子对应 ResNet 特征图把注意力权重归一化后映射成热力图叠在原图上。这里有一个细节attn_weight来自图像区域与文本 CLS token 的注意力如果权重均匀分布说明模型没有学到关键区域如果集中在人脸部位说明文本提示起了作用。答辩时放三张热力图比十页文字更有说服力。最后一件事保存模型和预测结果时把文本、图片路径、真实标签、预测标签、置信度都写进 CSV。这样能快速找到模型错在哪里。比如所有错误样本集中在“图像清楚但文本语义反转”的类型你就能在文档里写“门控融合相比拼接在反讽场景下 F1 提升了 5%原因是门控给文本语义分配了更高权重”。这种结论老师很吃。这套流程我从课设一直用到做横向项目唯一的教训是别在一开始就追求最复杂的融合结构基线拼接能跑通、结果正常再往上加门控或注意力。我的第一次方案直接写交叉注意力结果 BERT 和 ResNet 输入维度匹配就折腾了两天。后来老老实实把三种融合全部跑通发现最优的往往不是最复杂的而是让两个模态互相信任的那个。希望这份笔记能帮你在多模态情感分析这条路上少走这些弯路把精力留在真正有价值的地方——把模型调得更稳把报告讲得更清楚。本文还有配套的精品资源点击获取