
简介面向多模态情感分析任务的课程实验源码包基于预训练语言模型与残差卷积网络实现适合人工智能学生和开发者复现特征融合算法。项目覆盖两种朴素融合与三种注意力机制融合包含拼接、加权组合、跨模态注意力等策略可在五种融合模型间灵活切换对比文本与图像情感特征效果。压缩包共38个文件以Python源码、pyc编译文件、json/txt数据说明、png模型结构图为主并含使用介绍与依赖清单整体仅439KB便于快速部署。已有192人学习。该实验源自高校人工智能课程第五次实验覆盖数据预处理、建模范式、训练评估与推理调用流程其中注意力融合策略尤其适合捕捉跨模态交互特征可支撑课程设计、论文复现和算法入门参考。1. 多模态情感分析不是把两个模型拼在一起融合层才是主战场我拆这套基于BERTResNet的多模态情感分析系统源码时第一反应也是“这不就是两个预训练模型各跑各的最后拼一起嘛”。真跟下去才发现坑全在融合层。BERT吐出来的是768维的token级语义ResNet给的是2048维的图像全局特征两者的统计分布和粒度完全对不上直接concat出来的向量往往是高维空间里的“各说各话”。这套源码的价值在于它一次性实现了三种融合方法还配好了数据让你能完整走通多模态情感分析流程并理解“融合之后比单一模态强”这件事是怎么发生的。它最合适已经有Python和深度学习基础、想跑通多模态系统并搞清楚融合层怎么设计的开发者。2. 用BERTResNet做特征提取先对齐维度再谈融合2.1 文本端为什么我不用CLS向量而是用mean poolingBERT的最后一层输出是[batch, seq_len, 768]很多人习惯直接取[CLS]位置的向量当作整句表示。在单文本分类任务里CLS和mean pooling效果差不太多甚至CLS还快一点。但在多模态任务里文本特征要跟图像特征做交互CLS只代表“符合BERT预训练任务的句子级归纳”对词语级细节不敏感。图像那边输出的是一个全局特征如果文本端不带token级信息后面的cross-attention就没有query可用。所以源码里默认对last_hidden_state做mean pooling得到一个768维的句子向量同时也保留了token级输出用于attention融合。# 从BERT的last_hidden_state提取句子特征 # hidden: [batch, seq_len, 768], attention_mask: [batch, seq_len] def extract_text_feature(hidden, attention_mask): mask attention_mask.unsqueeze(-1).float() # [batch, seq_len, 1] mean_pooled (hidden * mask).sum(dim1) / mask.sum(dim1).clamp(min1e-9) return mean_pooled # [batch, 768]这里逻辑是用attention_mask把padding位置的token置零再按真实token数做平均。clamp防止除零也避免出现全mask的异常情况。这个做法的好处是特征不受padding长度影响训练更稳。源码里也有cls_pooler开关但那是一条备用路径默认别开。2.2 图像端ResNet50的2048维特征怎么抠出来ResNet预训练分类头的最后是1000类概率输出做特征提取必须把这一层摘掉。用torchvision加载resnet50把model.fc替换成Identity这样前向输出就是avgpool之后的2048维特征。输入图像要先resize成224x224再做ImageNet的mean/std归一化。这一步经常有人漏预处理不对ResNet出来的特征分布和预训练时完全不一致后面怎么调都白搭。import torchvision.models as models def build_image_encoder(pretrainedTrue, freeze_layers0): model models.resnet50( weightsmodels.ResNet50_Weights.IMAGENET1K_V1 if pretrained else None) # 去掉最后全连接保留avgpool输出 model.fc torch.nn.Identity() features model # 前向输出 [batch, 2048] if freeze_layers 0: frozen 0 for name, param in model.named_parameters(): if frozen freeze_layers: break param.requires_grad False frozen 1 return featuresfreeze_layers用来冻结前n层参数。一般冻结前5到10层能省不少显存也不会明显掉点。注意这里把fc改成Identity后输出直接就是2048维的全局池化特征不再是1000类的分类语义。如果你发现输出维度不对先查这里有没有改对。2.3 对齐把768和2048投影到同一维度两路特征维度不同直接concat会让融合层被迫去学“维度匹配”而不是学模态交互。所以源码里先各加一个投影头Linear LayerNorm把文本和图像都投影到256维。这里的256是hidden_dim是后面所有融合模块共享的维度。选256的原因是太小损失信息太大融合层参数爆炸。如果你显存宽裕可以改成512但效果提升有限。import torch.nn as nn class FeatureProjector(nn.Module): def __init__(self, in_dim, hidden_dim256, dropout0.1): super().__init__() self.proj nn.Sequential( nn.Linear(in_dim, hidden_dim), nn.LayerNorm(hidden_dim), nn.GELU(), nn.Dropout(dropout) ) def forward(self, x): return self.proj(x) text_proj FeatureProjector(768) image_proj FeatureProjector(2048)投影后的特征分别是[batch, 256]和[batch, 256]再做融合。GELU比ReLU更平滑多模态训练时梯度更稳dropout防止小数据集过拟合。注意如果要做残差连接别把投影前的原始特征直接加进来因为维度不同。模态原始特征维度投影维度说明文本768mean pooling256句子级表示图像2048avgpool256全局视觉特征选BERT和ResNet而不是更新的大模型是刻意的。BERT语义表示足够稳ResNet在图像特征上的性价比最高两个模型的预训练权重都好找社区踩坑经验也多。换成ViT和DeBERTa融合原理一样但参数量和设备要求会高一个量级。这套源码的核心价值在融合框架不在编码器本身。3. 把多模态数据送进模型DataLoader预处理与batch检查3.1 数据目录结构与配对关系资源包里data目录下大概是这样train目录里有texts文件夹、images文件夹、train.csv。csv每行三列text、image_path、label。label是情感极性比如0负面、1正面。多模态情感分析最忌讳的坑就是“文本和图像没对齐”。所以第一步先写一个检查脚本遍历csv里的image_path确认文件都在再确认图片能正常解码。我把这一步叫配准检查配不准后面全是白搭。3.2 自定义Dataset同时处理文本和图像用PyTorch的Dataset重写__getitem__返回一个dict里面是token输入、图像张量、标签。关键是图像读进来要做两件事转RGB防止灰度图或带alpha通道的PNG翻车再做ImageNet归一化。文本端tokenizer的max_len我设64情感评论一般不长超过64截断损失很少。如果你做长文本改成128或256显存和速度都会上去。import torch from torch.utils.data import Dataset from PIL import Image from torchvision import transforms 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]) ]) class MultiModalDataset(Dataset): def __init__(self, df, tokenizer, max_len64, image_dirimages): self.df df.reset_index(dropTrue) self.tokenizer tokenizer self.max_len max_len self.image_dir image_dir self.transform image_transform def __len__(self): return len(self.df) def __getitem__(self, idx): row self.df.iloc[idx] # 文本编码 enc self.tokenizer( row[text], truncationTrue, max_lengthself.max_len, paddingmax_length, return_tensorspt) # 图像读取灰度图统一转RGB img Image.open(f{self.image_dir}/{row[image_path]}).convert(RGB) img_tensor self.transform(img) return { input_ids: enc[input_ids].squeeze(0), attention_mask: enc[attention_mask].squeeze(0), image: img_tensor, label: torch.tensor(row[label], dtypetorch.long) }padding用max_length而不是动态padding会浪费一点显存但省事每个batch尺寸固定小batch下无所谓。transform的顺序不要搞反先ToTensor再Normalize。我在这里栽过一次忘了convert(RGB)灰度图被PIL当成单通道训练时ResNet特征分布跟预训练不一致指标悄悄掉两三个点。3.3 训练前的检查脚本先跑通一个batch不要一上来就train写一个几行的脚本把DataLoader里第一个batch打出来确认每个tensor的shape和dtype。这一步能拦下80%的“训练时突然报错”。from torch.utils.data import DataLoader dataset MultiModalDataset(train_df, tokenizer) loader DataLoader(dataset, batch_size4, shuffleTrue) batch next(iter(loader)) for k, v in batch.items(): if torch.is_tensor(v): print(k, v.shape, v.dtype)正常输出应该是input_ids [4,64] int64attention_mask [4,64] int64image [4,3,224,224] float32label [4] int64。如果image的通道数不是3去查图像的读取和transform。如果input_ids第二维不是64查tokenizer的max_length有没有传对。3.4 batch size和num_workers的取舍两个编码器都很吃显存。BERT base加ResNet50输入一个224的图加64个token单样本前向大概要500MB所以batch size从8起步比较稳。RTX 3090可以到16普通16G显存建议8。num_workers在Linux可以开4Windows开0或2开高了DataLoader容易卡死。如果显存不够优先把图像resize从224降到192再考虑梯度累积。图像尺寸对情感任务的影响比文本长度更大因为情感信息多在全图语义缺一点细节影响不大。数据准备这部分我一般会写成一个独立的prepare_data.py换数据集只需要改csv路径和图像目录别把路径写死在Dataset里。后面你要跑多种融合方法对比会发现可复用的数据管线比模型本身更省时间。4. 多种融合方法实现从concat到cross-attention的参数与取舍4.1 最简单但最常被用错的concat融合concat就是把两个256维向量拼成512维再接分类头。这是基线中的基线。但很多人做错直接把BERT和ResNet的原始维度concat而不是先投影或者concat后直接接ReLULinear没有dropout。正确做法是先把两个模态投影到256维再拼接分类头中间层加dropout。import torch import torch.nn as nn class ConcatFusion(nn.Module): def __init__(self, hidden_dim256, num_classes2, dropout0.3): super().__init__() self.classifier nn.Sequential( nn.Linear(hidden_dim * 2, hidden_dim), nn.ReLU(), nn.Dropout(dropout), nn.Linear(hidden_dim, num_classes) ) def forward(self, text_feat, image_feat): fused torch.cat([text_feat, image_feat], dim-1) return self.classifier(fused) # [batch, num_classes]这个融合方式优点是实现简单、好调参缺点是它没有建模“文本和图像哪里互相呼应”。所以在模态关联强的任务上concat通常只能超过单模态几个点。它适合做基线用来确认数据配准和训练管线没问题。4.2 Cross-Attention融合让模态互相“提问”真正的cross-attention要求文本保留token级特征不能只用mean pooling。这里实现的是文本token作为query图像全局特征作为key/value。也就是每个文本位置去图像里“问一次”拿回一个加权后的图像表示最后在序列维度上平均。import torch.nn.functional as F class CrossAttentionFusion(nn.Module): def __init__(self, hidden_dim256, num_heads4, dropout0.1): super().__init__() self.num_heads num_heads self.head_dim hidden_dim // num_heads self.q_proj nn.Linear(hidden_dim, hidden_dim) self.k_proj nn.Linear(hidden_dim, hidden_dim) self.v_proj nn.Linear(hidden_dim, hidden_dim) self.out_proj nn.Linear(hidden_dim, hidden_dim) self.dropout nn.Dropout(dropout) self.norm nn.LayerNorm(hidden_dim) def forward(self, text_tokens, image_feat): # text_tokens: [batch, seq_len, 256] # image_feat: [batch, 256] batch, seq_len, _ text_tokens.shape img image_feat.unsqueeze(1) # [batch, 1, 256] q self.q_proj(text_tokens).view( batch, seq_len, self.num_heads, self.head_dim).transpose(1, 2) k self.k_proj(img).view( batch, 1, self.num_heads, self.head_dim).transpose(1, 2) v self.v_proj(img).view( batch, 1, self.num_heads, self.head_dim).transpose(1, 2) attn torch.matmul(q, k.transpose(-2, -1)) / (self.head_dim ** 0.5) attn F.softmax(attn, dim-1) attn self.dropout(attn) out torch.matmul(attn, v) # [batch, heads, seq_len, head_dim] out out.transpose(1, 2).reshape(batch, seq_len, -1) out self.out_proj(out) return self.norm(out.mean(dim1)) # [batch, 256]图像特征是一个全局向量expand成1个token后注意力就变成“每个文本token去看一眼图像整体”。如果想要图像也反过来看文本源码里用两个这样的模块对称拼接。num_heads一般4到8head_dim必须能整除hidden_dim。attention dropout至少0.1否则小数据上很快过拟合。需要特别提醒用这个融合文本编码器不能只返回mean pooling要保留所有token的投影结果。我在一开始没注意改成cross-attention之后发现文本侧没有token级向量又回去改编码器浪费了半天。4.3 Gate融合用门控向量控制信息量门控融合的思路是学一个0到1之间的门控制文本特征和图像特征各有多少进入最终分类。它比concat多了一个交互门控信号来自两个模态的拼接而不是简单相加。class GateFusion(nn.Module): def __init__(self, hidden_dim256, num_classes2, dropout0.1): super().__init__() self.gate nn.Sequential( nn.Linear(hidden_dim * 2, hidden_dim), nn.ReLU(), nn.Linear(hidden_dim, hidden_dim), nn.Sigmoid() ) self.fusion_norm nn.LayerNorm(hidden_dim) self.classifier nn.Sequential( nn.Linear(hidden_dim, hidden_dim), nn.ReLU(), nn.Dropout(dropout), nn.Linear(hidden_dim, num_classes) ) def forward(self, text_feat, image_feat): g self.gate(torch.cat([text_feat, image_feat], dim-1)) fused g * text_feat (1 - g) * image_feat fused self.fusion_norm(fused) return self.classifier(fused) # [batch, num_classes]门控输出和文本特征逐元素相乘那个位置如果是0最终特征全由图像贡献是1则全由文本贡献。这样模型可以学会在不同维度上选择模态。它的参数量介于concat和cross-attention之间训练比cross-attention稳不容易出现一个模态压制另一个模态的情况。我实际用下来门控在类别数多的时候优势更明显因为不同类别确实需要不同模态的证据。4.4 融合层的分类头与训练参数损失函数用CrossEntropyLoss优化器用AdamW。重点是学习率不能一样BERT用2e-5ResNet用1e-4融合层和分类头用5e-4。融合层是随机初始化的如果lr太小它学得比编码器慢很多整个模型不平衡。import torch optimizer torch.optim.AdamW([ {params: text_encoder.parameters(), lr: 2e-5}, {params: image_encoder.parameters(), lr: 1e-4}, {params: fusion.parameters(), lr: 5e-4}, {params: classifier.parameters(), lr: 5e-4} ], weight_decay0.01)学习率调度我用linear warmup加cosine decaywarmup steps取总steps的10%。多模态训练很容易在前几百步loss爆发warmup能稳住。如果你是快速复现先跑10个epoch看趋势不用等它跑满。融合方法主要参数参数量倾向稳定性适用场景concathidden_dim, dropout大高基线、快速验证cross-attentionnum_heads, head_dim中低需调参模态间有强关联gatehidden_dim, gate结构小高通用、稳健选哪种融合不是拍脑袋。我的习惯是先用concat把整条管线跑通确认数据和loss没问题然后换成gate看F1涨不涨最后才上cross-attention。cross-attention理论上限最高但训练曲线很“玄学”一会涨一会跌需要回调去调。如果你时间紧gate是最靠谱的选择。这套源码里跑的时候直接指定--fusion模式就能切换对比起来很方便。5. 避坑指南多模态训练里最常翻车的五个位置多模态训练出问题的时候最难的不是看代码是定位问题在哪一侧。我一般按“数据配准、预处理、编码器、融合层、训练配置”的顺序排查。下面五个坑是按出现频率排的。5.1 loss震荡不收敛先查学习率分组现象训练到第3个epochloss还在0.7附近来回跳train accuracy上不去甚至越来越高。原因最常见的就是整个模型共用一个学习率比如1e-3这对BERT来说是灾难性的几个step就能把预训练权重冲坏。解决改成按模块分学习率文本2e-5图像1e-4融合和分类头5e-4。我见过很多项目跑不起来改完分层学习率立刻就稳了。5.2 验证集分数异常低先排除数据配准和泄漏现象训练集loss正常下降验证集F1却一直比随机猜测还低。原因除了过拟合更常见的是数据划分没有按label分层或者同一个文本图像对同时出现在train和val。这种泄漏会让模型在验证集上看到的样本和训练时高度相似但指标反而“虚高”一旦做严格划分就崩。解决用StratifiedShuffleSplit做分层划分并按image_path做去重。划分完后随机抽20对人工确认训练集和验证集没有重叠。5.3 图像分支一改就掉点预处理必须走ImageNet标准现象同一个模型有人复现出80%的准确率你一跑只有75%而且怎么调都回不来。原因大概率图像预处理不对。最常见的错误是只resize不normalize或者把Normalize的mean/std写反。ResNet预训练权重对输入分布很敏感。解决严格用mean[0.485,0.456,0.406]std[0.229,0.224,0.225]先ToTensor再Normalize。如果图像里有灰度图记得convert(RGB)否则通道分布不一样。5.4 显存爆掉冻结策略和gradient checkpoint现象batch size刚设到16直接OOM改成4能跑但训练慢得离谱。原因两个编码器都要回传梯度计算图全部保留在显存里BERT加ResNet的激活值很占空间。解决先冻结ResNet前几层用build_image_encoder里的freeze_layers参数再不行给BERT开gradient checkpointing用torch.utils.checkpoint包裹Transformer层。还有一种技巧如果融合层很简单可以只让一个编码器回传梯度另一个前向时用detach()等于那条分支退化成固定特征提取器。5.5 融合不如单模态先跑基线再谈融合现象费劲训完融合模型发现F1比只用文本还低。原因不是融合结构有问题而是模态间没有对齐或融合层复杂度跟数据量不匹配。concat基础版本在弱关联数据上经常被单一强模态压制。解决先分别训文本单模和图像单模记录各自分数再训融合观察是否有提升。如果融合比不上最优单模先降融合层学习率到1e-4或者换成gate融合。这一步看着简单但能帮你省掉大量在融合结构上乱试的时间。6. 验证你的融合方案跑单模态基线看融合增益判断融合是否有效不能只看一个准确率。正确做法是跑三个对照组只用文本、只用图像、融合分别记录F1和AUC。融合至少要稳定超过两个单模态中的最好者才说明融合结构真的学到了东西。from sklearn.metrics import classification_report, confusion_matrix def evaluate(model, loader, devicecuda): model.eval() all_pred, all_true [], [] with torch.no_grad(): for batch in loader: input_ids batch[input_ids].to(device) attention_mask batch[attention_mask].to(device) image batch[image].to(device) logits model(input_ids, attention_mask, image) pred logits.argmax(1) all_pred pred.cpu().tolist() all_true batch[label].cpu().tolist() print(classification_report(all_true, all_pred)) print(confusion_matrix(all_true, all_pred))如果融合比文本低但比图像高说明融合被图像拖累了可以调低图像分支的学习率或增加投影层dropout。如果三个分数都差不多先怀疑数据太简单模型早就饱和。这样你至少知道钱花在哪里。我还有一个习惯跑完cross-attention之后把attention weight导出来可视化文本每个词对图像的注意力值。有时能看到模型在关注噪声背景而不是人脸这比任何指标都直观能快速定位模型是否在瞎学。有一次我为了赶deadline跳过单模态基线直接跑融合结果融合分数比想象高我还以为模型很强。后来补测单模才发现原来单模本来就不低融合等于白做。从那以后我每次换数据集都强制先跑单模基线再跑融合用增益说话。希望帮到你。本文还有配套的精品资源点击获取