
简介面向计算机专业毕业设计与期末大作业的Python多模态情感分析项目基于BERT与ResNet进行文本和图像双模态情感识别适合正在完成课题的学生以及需要项目实战的Python学习者。项目为作者本科毕业设计经导师指导获得评审分99分代码完整可运行。项目实现了多种融合方法包括朴素拼接、跨模态注意力机制、隐状态Transformer编码器等并配有训练预测脚本、数据预处理器、配置文件和说明文档覆盖从数据处理到模型对比的完整流程。压缩包共39个文件以17个py源码、12个pyc缓存、3个txt说明、3个png结构图和2个json数据文件为主整体仅445KB轻量便携目录按模型、工具、数据等模块划分清晰另有README说明文件方便直接运行与二次开发。目前已有140人学习浏览对于课程设计、期末大作业或毕业设计都有较高参考价值可帮助快速上手多模态情感分析并理解不同融合策略的实际效果。1. Python多模态情感分析文本图像为什么值得同时做做多模态情感分析的第一年我最深的感受是单独看 BERT 的文本效果和 ResNet 的图像效果都很好但两个模型放在一起结果经常莫名其妙变差。当时用文本单模跑出 88% 的准确率加上图像特征后反而掉到 82%整个人是蒙的。后来才意识到问题基本不在模型本身而在文本与图像的数据对齐和融合方式上。标题里的「Python多模态情感分析」核心就是用 BERT 提取文本情感信号用 ResNet 提取图像情绪信号再用不同的融合方法把两路特征合成一个决策。这个方案适合舆情系统、商品评论分析、社交媒体内容审核以及任何同时出现文字和图片的场景。下面按我从数据预处理、特征提取、融合到调参验证的完整路径来讲每一步给出可运行的代码片段和参数说明最后把最容易让模型翻车的四个坑单独拎出来讲清楚。2. 先解决数据文本与图像预处理的最小闭环多模态项目拿到手的那一刻最先翻车的通常不是模型而是数据。文本侧有的长有的短图像侧有的清晰有的带水印最麻烦的是文本和图像的配对关系经常是乱的。如果这一层不做干净后面所有融合方法都是白搭。常见做法是把样本组织成「一条记录 一段文本 一张图片 一个情感标签」的格式然后再进两个独立的预处理管道。2.1 文本侧BERT 分词与截断策略文本预处理看起来只是调一下分词器实际上有两个坑一个是序列长度怎么截另一个是 padding 时 attention_mask 别丢掉。BERT 默认支持最长 512 token但情感分析这种任务绝大多数文本的有效信息集中在前 64 到 128 个 token 里盲目拉到 512 反而会让模型被无关内容干扰训练速度也慢。我一般用bert-base-chinese作为基线把max_length设成 128先跑一版看看效果。如果训练集里文本长度分布比较长再放宽到 256。长度参数直接影响显存和训练速度128 的 batch 可以开到 32512 的 batch 可能 16 就显存吃紧。以下是最小可用的文本预处理代码from transformers import BertTokenizer tokenizer BertTokenizer.from_pretrained(bert-base-chinese) def text_to_features(text: str, max_length: int 128): encoded tokenizer( text, max_lengthmax_length, paddingmax_length, truncationTrue, return_tensorspt # 直接返回 PyTorch Tensor ) return encoded[input_ids], encoded[attention_mask]这段代码里最值得留意的是return_tensorspt它会帮你把分词结果直接转成torch.Tensor省掉一次手动转换。paddingmax_length会把所有样本统一成相同长度虽然有点浪费算力但在 batch 训练时是最省心的做法。attention_mask标注哪些位置是真实 token、哪些是 paddingBERT 的前向计算会靠它屏蔽填充位不要省略。实际项目里还有一类文本是带 emoji 和超链接的。BERT 的 tokenizer 不会把 emoji 识别成有用特征建议在进分词器之前做一次轻量清洗超链接可以替换成一个特殊占位符比如[URL]连续重复的标点压缩成单个。清洗逻辑要放在text_to_features外面保持文本处理的可复用性。2.2 图像侧ResNet 输入规范与数据增强图像预处理相对固定ResNet 系列在 ImageNet 上预训练时用的是 224×224 的输入和固定的均值方差。如果直接把任意尺寸的图片塞进去模型前向计算会报错或者跑出完全不可信的特征。常见做法是先用双线性插值缩放再中心裁剪到 224×224然后按 ImageNet 的统计量做归一化。缩放和裁剪的顺序会影响信息保留程度。对于情感分析里的图片最需要保留的是人脸表情和物体场景直接拉伸到正方形会让部分图片变形严重。我惯用的做法是先短边缩放到 256再中心裁剪 224这样既保持长宽比又让裁剪区域覆盖图片中央的主体。训练阶段再叠加随机翻转和颜色抖动来缓解过拟合。import torch from torchvision import transforms image_transform { train: transforms.Compose([ transforms.Resize(256), transforms.RandomCrop(224), transforms.RandomHorizontalFlip(), transforms.ColorJitter(brightness0.2, contrast0.2), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]), val: transforms.Compose([ transforms.Resize(256), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) }RandomHorizontalFlip对于情感分析是安全的增强方式因为物体倒影不会改变情感倾向。ColorJitter能增强模型对光照变化的鲁棒性但幅度不要太大否则会把肤色和色调信息洗掉反而干扰情感判断。验证集不做随机增强只做缩放裁剪和归一化否则验证曲线会忽高忽低没法准确判断模型是否收敛。这里有一个容易被忽略的小问题读取图片时建议用Image.open(...).convert(RGB)统一转成三通道。灰度图如果不转换会变成单通道张量ResNet 的卷积层输入维度不对直接报错。转换之后再走上面的 transform整体就稳定了。2.3 配对样本的对齐与清洗文本和图像各自预处理完之后还需要保证它们能按照样本 ID 一一对应。实际数据集中经常出现「文本张冠李戴、图片缺失、标签过期」三类问题。最可靠的做法是在构建 Dataset 之前跑一个对比脚本把文本记录里的图片路径都检查一遍确认文件存在且能正常打开。我一般会建立一个统一的索引文件比如 CSV 结构为id, text, image_path, label。加载数据集时以id为主键文本侧和图像侧都挂在这个 ID 下面。如果某条记录缺图片要么整条丢弃要么补一张纯色占位图并把这个样本标记成低置信度。丢弃还是补齐取决于项目目标做研究时直接丢弃更干净做线上服务时必须考虑缺模态的兜底策略可以在融合模型里单独练一个只用文本的 head 来接这种样本。以下是一个简单的配对校验逻辑放在训练前作为数据管道的入口import os import pandas as pd def validate_pairs(meta_path: str): df pd.read_csv(meta_path) valid_rows [] for _, row in df.iterrows(): if not isinstance(row[text], str) or len(row[text]) 0: continue if not os.path.exists(row[image_path]): continue valid_rows.append(row) valid_df pd.DataFrame(valid_rows) print(f原始样本数 {len(df)}过滤后样本数 {len(valid_df)}) return valid_df这个函数的意义在于让后续所有训练代码都只关注「已经成对的数据」不要在每个 batch 里再做条件判断。过滤逻辑集中放在最前面比散落在 Dataset 类里更容易排查问题。跑完校验之后顺手打印一下标签分布如果某个类别的样本数少得可怜后面训练时就要考虑加权采样这个在调参阶段再展开。3. BERT 与 ResNet 特征提取吃透两个预训练模型的输出数据准备齐了接下来就是多模态模型的核心让两个预训练模型各吐出一个高维特征向量。我见过不止一个人把 BERT 的输出整串丢进全连接层结果维度对不上报错报得人头皮发麻。这一章把 BERT 和 ResNet 的输出结构讲透再给一个可以直接封装复用的特征提取器。3.1 BERT从 CLS 向量到序列级特征BERT 的最后一层输出是一个三维张量形状是(batch_size, seq_len, hidden_size)。做句子级情感分析时最常见的做法是取[CLS]这个特殊 token 对应的向量也就是序列第一个位置它的设计初衷是聚合整句话的信息。取法很简单from transformers import BertModel bert BertModel.from_pretrained(bert-base-chinese) bert.eval() with torch.no_grad(): outputs bert(input_idsinput_ids, attention_maskattention_mask) last_hidden outputs.last_hidden_state # (batch, seq_len, 768) cls_vec last_hidden[:, 0, :] # (batch, 768)cls_vec就是 BERT 给整句话打的压缩包维度固定是 768。这里有一个实际体验CLS 向量对短文本情感分类表现不错但对长文本或者含复杂转折的句子信息压缩会损失细节。比如「虽然环境很差但服务员态度极好」这种句子CLS 可能只捕捉到「好」把转折信息丢了。另一个可选方案是取最后一层所有 token 的平均池化这样每个词的信息都会参与但缺点是不相关的词也会进来稀释情感信号。我的经验是先用 CLS 跑 baseline如果验证集在难点样本上表现不佳再试 mean pooling哪个高用哪个不用过度纠结理论。需要留意的是BERT 前向最好包在torch.no_grad()里特别是只做特征提取不做微调的时候能省下大量显存和计算时间。3.2 ResNet从全局池化到多尺度特征ResNet 的常规输出不是三维张量而是已经过全局平均池化的二维特征形状是(batch_size, 2048)。以resnet50为例最后的全连接分类层之前是一个AdaptiveAvgPool2d(1)把7×7×2048的特征图压成1×1×2048。因此直接拿到的是一个 2048 维的图片语义向量。用 torchvision 加载预训练模型时如果不想要后面的分类头可以通过children()截断或者直接把fc替换成恒等映射。我习惯保留整个模型结构只用forward中间变量这样修改最小import torch.nn as nn from torchvision import models resnet models.resnet50(weightsmodels.ResNet50_Weights.IMAGENET1K_V1) resnet.fc nn.Identity() # 把分类层换成恒等映射forward 输出变成特征向量 with torch.no_grad(): image_feat resnet(images) # (batch, 2048)这里最容易踩的坑是weights参数写错。在较新版本的 torchvision 中pretrainedTrue已经被弃用正确写法是models.ResNet50_Weights.IMAGENET1K_V1。如果你用的是旧版本API 可能还能兼容但建议迁移到新写法避免后续升级时报错。2048 维向量代表的是全局语义但情感分析有时需要局部信息比如图片角落里的一抹笑容或一个愤怒的手势。全局池化会把这些局部特征平均掉。如果发现纯 ResNet 特征表达力不够可以改成resnet.layer4的输出先做一次全局池化再得到特征或者用多尺度特征拼接但代价是融合层输入维度会更高训练压力更大。3.3 把两个模型封装成一个多模态特征提取器实际工程里不可能每次训练都手写一遍 BERT 和 ResNet 的调用逻辑应该把它们封装成一个模块输入文本和图像输出两个特征向量。这个模块设计得好不好直接影响后面换融合方法的成本。我通常会写成一个MultimodalExtractor内部持有 BERT、ResNet 和一个可选的投影层。投影层的作用是把两个特征的维度统一到同一个空间方便后面做门控或者注意力融合。BERT 的 768 维和 ResNet 的 2048 维差距有点大直接拼接虽然可行但门控融合时维度高的模态容易主导梯度。import torch import torch.nn as nn class MultimodalExtractor(nn.Module): def __init__(self, text_dim768, image_dim2048, proj_dim256): super().__init__() self.bert BertModel.from_pretrained(bert-base-chinese) self.resnet models.resnet50(weightsmodels.ResNet50_Weights.IMAGENET1K_V1) self.resnet.fc nn.Identity() self.text_proj nn.Linear(text_dim, proj_dim) self.image_proj nn.Linear(image_dim, proj_dim) self._freeze_backbones(freeze_bertTrue, freeze_resnetTrue) def _freeze_backbones(self, freeze_bert: bool, freeze_resnet: bool): for param in self.bert.parameters(): param.requires_grad not freeze_bert for param in self.resnet.parameters(): param.requires_grad not freeze_resnet def forward(self, input_ids, attention_mask, images): with torch.no_grad(): bert_out self.bert(input_idsinput_ids, attention_maskattention_mask) text_feat bert_out.last_hidden_state[:, 0, :] # (batch, 768) image_feat self.resnet(images) # (batch, 2048) text_feat self.text_proj(text_feat) # (batch, 256) image_feat self.image_proj(image_feat) # (batch, 256) return text_feat, image_feat模块里默认把两个主干网络全部冻结只训练投影层。这么做是基于一个很现实的原因多模态训练集通常只有几千到几万条把 BERT 和 ResNet 全部解冻训练模型会迅速过拟合而且在单卡上训练时间会翻好几倍。投影层的 256 维是经验和显存折中的结果维度太低表达力不足维度太高后面融合层的参数量爆炸。with torch.no_grad()放在 forward 内部有个好处不管外层是否开梯度主干网络都不会产生梯度图显存开销被压得很低。如果你想做全参数微调把_freeze_backbones的参数改成False即可但前面说的过拟合风险依然存在。4. 多种融合方法从拼接门控到跨模态注意力前面准备好的两个特征向量就好比两个候选人各自提交了一份简历融合方法决定了最终怎么综合两个人的意见。这一章讲三种常用的融合方式早期拼接、门控融合、跨模态注意力。它们不是互斥的实际项目里可以把门控和注意力叠加着用。4.1 早期拼接融合Concat最简单的 baseline拼接融合是最容易想到的方案把 768 维文本特征和 2048 维图像特征直接拼成 2816 维然后过一个分类头。它虽然简单但作为 baseline 非常必要因为如果你连拼接都跑不过后面的复杂融合基本也不用期待。import torch.nn as nn class ConcatFusion(nn.Module): def __init__(self, text_dim768, image_dim2048, num_classes3): super().__init__() 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): fusion_feat torch.cat([text_feat, image_feat], dim-1) return self.classifier(fusion_feat)拼接本身没有任何可调参数所以坑全在后面的分类头。第一层Linear的输入维度必须写成text_dim image_dim多模态项目最常见的报错就是把这里写成单个模态的维度。另一个细节是拼接前最好对两个特征做标准化或者 L2 归一化否则特征值尺度差异过大会让分类头偏向大数值那一路。拼接融合的优点是实现简单、调试直观可以快速检验两个模态的特征质量。缺点是它隐含了一个假设两个模态对最终情感的贡献是静态的。但现实中有些样本文本信息量大、图像信息少另一些样本反过来拼接没办法动态调整权重这给了门控融合发挥的空间。4.2 门控融合让模型自己学会调两个模态的权重门控融合的核心想法是给每个模态算一个权重让模型根据具体样本来决定更信文本还是更信图像。这里的门控信号可以从两个特征的拼接结果里学出来也可以从单独某个模态里生成。实践中用拼接结果算门控最稳定因为信息来源最全。import torch import torch.nn as nn class GatedFusion(nn.Module): def __init__(self, text_dim768, image_dim2048, num_classes3): super().__init__() self.gate nn.Sequential( nn.Linear(text_dim image_dim, 128), nn.ReLU(), nn.Linear(128, 1), nn.Sigmoid() ) self.classifier nn.Linear(text_dim image_dim, num_classes) def forward(self, text_feat, image_feat): concat_feat torch.cat([text_feat, image_feat], dim-1) g self.gate(concat_feat) # (batch, 1)0~1 之间 weighted_text g * text_feat weighted_image (1 - g) * image_feat fusion_feat torch.cat([weighted_text, weighted_image], dim-1) return self.classifier(fusion_feat)门控输出的标量g接近 1 时模型偏向文本接近 0 时偏向图像。这里有一个工程细节weighted_text和weighted_image的维度依旧分别是text_dim和image_dim如果text_dim等于image_dim还可以直接相加替代拼接进一步压缩分类头的输入维度。但维度不等时拼接后再过分类头更简单。门控融合在文本和图像质量参差不齐的数据集上提升明显尤其是存在大量「只有文本信息、图像纯装饰」的样本时门控能把图像权重压到很低变相实现了模态选择的兜底。代价是门控网络自己也会过拟合门控部分需要加 Dropout上面代码里虽然没加但实际建议在两层之间加一个nn.Dropout(0.2)。4.3 跨模态注意力融合让文本和图像互相引导跨模态注意力是三种方法里参数最多、也最容易过拟合的一种。它的思路不再是一个全局权重而是让文本特征作为查询去关注图像特征的不同位置或者反过来让图像特征作为查询去关注文本特征的每个 token。对情感分析而言文本里可能有一句「这画面太美了」但美在图片的哪个区域需要模型自己对齐。为了控制参数量我不会直接在高维原特征上做注意力而是先把两个模态投影到同一个低维空间再计算注意力分数。下面是一个简化的文本查询图像实现import torch import torch.nn as nn import torch.nn.functional as F class CrossModalAttention(nn.Module): def __init__(self, text_dim768, image_dim2048, proj_dim128): super().__init__() self.text_query nn.Linear(text_dim, proj_dim) self.image_key nn.Linear(image_dim, proj_dim) self.image_value nn.Linear(image_dim, proj_dim) self.text_proj nn.Linear(text_dim, proj_dim) self.classifier nn.Linear(proj_dim proj_dim, 3) def forward(self, text_feat, image_feat): query self.text_query(text_feat) # (batch, proj_dim) key self.image_key(image_feat) # (batch * N, proj_dim) 简化示意 value self.image_value(image_feat) score torch.matmul(query, key.transpose(-2, -1)) / (key.size(-1) ** 0.5) attn_weight F.softmax(score, dim-1) attn_out torch.matmul(attn_weight, value) # (batch, proj_dim) text_out self.text_proj(text_feat) # (batch, proj_dim) fusion_feat torch.cat([text_out, attn_out], dim-1) return self.classifier(fusion_feat)上面的代码为了可读性把图像特征当成了一个整体 token实际使用时通常需要保留图像的空间位置也就是把resnet.layer4输出的特征图按通道展开成H×W个位置再让文本去关注这些位置。那个情况下image_feat的形状是(batch, H*W, dim)注意力分数的形状会变成(batch, seq_len, H*W)含义是文本每个 token 对图片每个空间位置的关注度。跨模态注意力的优势是能抓住细粒度的跨模态联系比如文本里的「红色」去对齐图片中的某个区域。但它训练更慢、更容易过拟合在几千条的小数据集上经常跑不出比拼接更高的效果。我的建议是先跑拼接和门控如果 baseline 效果不理想且数据量过万再上注意力融合这样能少走很多弯路。融合方式输入维度参数量训练难度适用场景早期拼接text_dimimage_dim低简单快速验证 baseline门控融合text_dimimage_dim中适中模态质量不均、有缺失跨模态注意力proj_dim 为主高困难大样本、细粒度跨模态对齐5. 融合实战避坑四个让模型翻车的硬伤这部分是把前面几章的坑汇总成四段踩坑记录每一条都是我在实验里真真切切碰到过的按照「现象 → 原因 → 解决」的顺序来说方便你直接对照排查。5.1 维度不匹配Linear 层最常见报错现象训练脚本跑到第一个 batch 就报类似mat1 and mat2 shapes cannot be multiplied (32x768 and 2816x512)的错。原因拼接融合后特征维度是76820482816但分类头第一层写成了nn.Linear(768, 512)只考虑了文本维度。这种错在代码里非常隐蔽因为单模实验跑得好好的一上多模态就崩。解决不要靠肉眼数维度。在 forward 里加一行print(fusion_feat.shape)把真实维度打出来然后按照打出来的数字去改分类头。更稳妥的做法是分类头初始化时接收融合后的维度作为参数而不是硬编码数字。另外如果用了投影层统一维度务必确认两个投影层的输出维度一致否则后面的拼接会在第二个维度上炸掉。5.2 模态失衡加图像特征后效果反而变差现象单用 BERT 验证集准确率 88%拼接图像特征后掉到 82%而且图像部分的梯度范数几乎为 0。原因BERT 的文本特征和 ResNet 的图像特征尺度差太多图像特征被文本特征淹没。尤其文本是强信号时分类头几乎只学文本路径图像路径退化成无效分支。解决先做一个简单的特征标准化把文本和图像特征各自归一化到均值 0、方差 1。再把两个特征分别过一层独立的投影层让小维度特征有机会被放大。最后考虑门控融合替代拼接让模型在样本级动态调节两个模态的贡献。如果这三种手段都上了图像特征还是不起作用那就要检查图像预处理是否破坏了关键信息比如裁剪位置把主体裁掉了。5.3 数据泄漏验证集准确率 0.98测试却一塌糊涂现象交叉验证时曲线非常漂亮测试集上准确率暴跌差 20 个百分点以上。原因多模态数据集里同一个事件产生的多张图片和多条文本被随机划分到了训练集和验证集。模型其实记住了文本里的命名实体和图片背景并没有真正学会情感特征。情感分析对这类泄漏尤其敏感因为同一主题的样本情感倾向高度相似。解决划分数据时要以「样本组」为单位而不是以单条记录为单位。比如微博评论一组包含多张图和对应的转发文本这整组只能全部进训练集或验证集。实现时可以用sklearn.model_selection.GroupShuffleSplit把组 ID 传进去。顺便检查一下有没有重复的文本或重复的图片路径这些也要去重后再划分否则照样泄漏。5.4 显存不足单卡训练直接 OOM现象跑了几个 epoch 后突然报CUDA out of memory有时是显存不够有时是显存碎片化。原因BERT 加 ResNet 两个大模型同时前向中间激活值占用的显存远超单模项目。如果还把整条计算图都保留下来做反向传播显存占用直接翻倍。解决先用多模态特征提取器里默认的冻结策略让主干网络只跑前向不反传。再把 batch size 降到 16 或 8同时把图片短边缩放到 224 而不是 256能省出可观显存。如果还想加 batch打开 PyTorch 的torch.utils.checkpoint做梯度检查点用计算换显存。最后每次 backward 之后手动torch.cuda.empty_cache()可以缓解碎片化但不要频繁调用否则会拖慢速度。6. 调参与验证用最小实验拿到可信结论多模态模型的可信度不在于跑出多高的训练准确率而在于验证方法是否严谨。我自己的习惯是先在十分之一的数据集上跑一个 20 步的冒烟实验确认 loss 能下降维度都在线再放开全量训练。这一步能过滤掉八成以上的代码问题比反复调参划算得多。训练配置上先把 BERT 和 ResNet 全部冻结只训练融合层和分类头学习率设在1e-3。如果融合层本身有两层以上的 MLP学习率降到5e-4更稳。当基础版本跑通后再逐步解冻 BERT 的最后一层学习率降到2e-5这是最不容易把预训练权重冲坏的顺序。验证时要单独做消融对比文本单模、图像单模、拼接融合、门控融合四组跑同一个验证集。如果融合模型连两个单模都打不过说明数据对齐或调参有问题这时候不要继续加注意力复杂度应该回头查上一章的坑。一般情况下融合模型比最好的单模高 2 到 5 个百分点是正常区间超过 8 个点就要怀疑验证集是否泄漏。最后一件事是固定随机种子包括 Python 的random、NumPy 和 PyTorch 的 CUDA seed否则同样参数跑两次结果不一样你根本分不清是改代码生效还是随机波动。我第一次跑多模态融合的时候吃过这个亏调了一整天参最后发现是没固定种子同一个实验两次能差 3 个百分点。从那以后固定种子成了我的习惯。多模态融合没有银弹但把数据对齐、维度检查、种子固定这三件事做到位你已经能超过大多数直接上来就堆模型的方案。希望帮到你。本文还有配套的精品资源点击获取