资讯动态

多模态入门实战:基于CNN与LSTM的图像描述系统搭建

发布时间:2026/10/19 10:27:51 来源:尧图企业网站定制
先说明一下这个项目我做完之后整体感觉就是Image Caption是真的把视觉和语言两套体系打通的一道坎。做完它你对多模态这个词的理解会跟做纯分类、纯文本任务完全不一样。这篇就按我实际动手的思路来写把数据处理、模型搭建、训练细节、推理优化、还有我踩过的坑一并记录下来。适合那些已经能熟练使用PyTorch跑通分类或RNN基础任务、想再往前跨一步的开发者参考。1. 整体思路拆解为什么用CNN编码器 RNN解码器1.1 核心任务定义图像描述Image Caption目标很直接给模型一张图模型输出一句完整自然语言来描述图中内容。比如输入一张小猫坐沙发的照片期望输出A cat sitting on a sofa。听起来挺简单但拆开看模型必须同时解决两个完全不同性质的问题一是从像素中提取语义信息二是把这些语义组织成符合语法规则、顺序合理的句子。这两个问题恰好对应深度学习里两大主流架构卷积神经网络擅长处理空间信息循环神经网络擅长处理序列信息。所以最经典、也最合理的方案就是把两者拼起来形成一个编码器-解码器结构。1.2 架构选型的逻辑我当初也纠结过要不要直接用Transformer干这件事。后来想明白了基础版用LSTM做解码器有几个现实好处代码量小训练曲线平稳对硬件要求低而且能让你把注意力集中在图像特征怎么和序列生成衔接这个核心问题上而不是一上来就被各种mask、位置编码、交叉注意力的细节淹没。整个流程一句话CNN负责把图看懂成一个向量RNN负责把这个向量说出来。看图就是编码讲故事就是解码。两者之间只有一个embedding维度的向量在传递信息但这个向量承载的是整幅图像的高层语义。1.3 这个方案能达到什么效果基础版本在COCO数据集约8万张训练图上训练10到15个epoch验证集BLEU-4能做到0.25左右CIDEr做到0.9左右。生成出来的句子在简单场景下已经相当像样比如A man riding a bike down a street这种级别的描述是完全没问题的。当然跟加了注意力机制、用了目标检测特征的顶会论文相比还有差距但作为理解和复现的基准这个成绩足够说明模型在真正学习图像和语言之间的映射关系而不是死记硬背。2. 数据处理与词表构建这步决定了模型天花板2.1 数据集选择与预处理的几个细节我使用的是COCO 2014数据集的训练和验证划分。这个数据集每张图带5句人工描述也就是说一张图等于5条训练样本这本身就是一个天然的数据增强手段。下载下来后第一个工作是把所有描述统一成小写清理标点逗号、句号、引号周围要处理干净把数字统一转成英文单词。这些小操作看着琐碎但直接影响词表质量。我第一版代码就是没把cat,和cat区分开词表里多出了大量带标点的噪音词导致词表虚胖模型还得花精力去学这些无意义区分。图像方面统一Resize到256x256训练时再做随机裁剪到224x224、随机水平翻转。验证和推理时直接用中心裁剪。归一化必须用ImageNet的均值和标准差这个很多人会忘忘掉的话模型收敛会慢得离谱。2.2 词表构建的取舍词表大小怎么定COCO全部单词去重后有几万个但很多词就出现一两次留着只会增加Embedding矩阵的参数量和稀疏性。我的做法是按词频过滤出现次数少于3次的词直接弃用不在词表里的词统一映射到特殊tokenUNK。最终词表控制在9000到10000左右这个规模在训练速度和词汇覆盖之间比较平衡。特别提醒一点SOS、EOS、PAD、UNK四个特殊token要提前占好索引。尤其PAD后面计算损失函数时候必须用它做遮蔽不然模型会被pad的部分带偏生成一堆空词。2.3 训练样本的序列化格式每个caption在喂给模型之前要转成token序列开头加SOS结尾加EOS然后按batch内最长序列做padding。训练时还需要拆成输入和目标两份输入是去掉最后一个token的序列目标是去掉最前面SOS的序列。这样每个时间步模型输入前一个词、预测下一个词。这个拆分逻辑虽然简单但第一次写的时候很容易搞反方向。3. 模型搭建实操编码器、解码器和初始化技巧3.1 编码器预训练ResNet的正确用法import torch import torch.nn as nn import torchvision.models as models class EncoderCNN(nn.Module): def __init__(self, embed_size): super(EncoderCNN, self).__init__() resnet models.resnet101(pretrainedTrue) # 去掉最后的平均池化和全连接分类层 modules list(resnet.children())[:-2] self.resnet nn.Sequential(*modules) self.adaptive_pool nn.AdaptiveAvgPool2d((1, 1)) self.fc nn.Linear(2048, embed_size) def forward(self, images): features self.resnet(images) # (batch, 2048, 7, 7) features self.adaptive_pool(features) # (batch, 2048, 1, 1) features features.view(features.size(0), -1) features self.fc(features) # (batch, embed_size) return features用resnet101的倒数第二层卷积特征图而不是最后一层全连接输出原因是卷积特征图保留了空间结构信息做全局平均池化后得到的向量信息更丰富。如果你后续想加注意力机制也得从这里下手。关于预训练参数怎么处理我的经验是如果显存允许比如单卡12GB以上把resnet的后两三个block解冻参与训练前面的部分冻结。这样图像特征能微调得更适配caption任务。如果显存紧张就全部冻结效果会打折扣但依然能跑。还有一个坑是BatchNorm在冻结和微调混合时的running_mean更新问题建议冻结模式下把BN层设成eval模式否则训练和推理时特征分布可能对不上。3.2 解码器LSTM为什么这么设class DecoderRNN(nn.Module): def __init__(self, embed_size, hidden_size, vocab_size, num_layers1, dropout0.5): super(DecoderRNN, self).__init__() self.embed nn.Embedding(vocab_size, embed_size) self.lstm nn.LSTM(embed_size, hidden_size, num_layers, batch_firstTrue, dropoutdropout) self.dropout nn.Dropout(dropout) self.fc nn.Linear(hidden_size, vocab_size) # 图像特征到LSTM初始状态的独立映射 self.init_h nn.Linear(embed_size, hidden_size) self.init_c nn.Linear(embed_size, hidden_size) def forward(self, features, captions): embeddings self.dropout(self.embed(captions)) # (batch, seq_len, embed_size) h0 self.init_h(features).unsqueeze(0) # (1, batch, hidden_size) c0 self.init_c(features).unsqueeze(0) outputs, _ self.lstm(embeddings, (h0, c0)) outputs self.fc(outputs) # (batch, seq_len, vocab_size) return outputs这里有两个细节值得讲。第一个是图像特征初始化LSTM的h0和c0。很多简单版本的代码直接把特征向量当h0用这就要求embed_size必须等于hidden_size非常不灵活。我改成两个独立线性层映射embed_size和hidden_size彻底解耦后面调参方便很多。第二个是关于LSTM层数。我对比过单层和双层双层表达能力强一些但训练时间接近翻倍而且在这个任务上并没有带来显著的BLEU提升。考虑到这是一个入门到进阶的过渡项目单层LSTM是性价比最高的选择。如果你增大数据集或者加了注意力机制再考虑层数也不迟。3.3 训练时的嵌入和Dropout细节Embedding层和LSTM输入之间加了一个Dropout(0.5)。这个很关键Image Caption模型不大但预训练ResNet提取的特征和词向量都容易过拟合尤其是在词频分布不均匀、很多长尾词只出现几次的情况下。Dropout放在Embedding之后对缓解过拟合有直接帮助。LSTM内部也有dropout参数但要说明的是nn.LSTM的dropout只对多层LSTM的非首层输入生效单层LSTM内部是无效的。所以如果你只有一层LSTM靠的是Embedding后面的Dropout和全连接前的Dropout。想要对单层LSTM内部做变分dropout需要自己实现这个暂时不是必须的。4. 训练全流程从损失函数到生成解码4.1 Teacher Forcing机制的理解训练时解码器每一步的输入用的是真实目标句子的前一个token而不是模型自己上一步的输出。这叫Teacher Forcing。这样做的核心原因是训练更稳定模型不会因为某一步出错而连锁错下去损失函数也能快速下降。但这里埋了一个隐患叫exposure bias训练时模型从没见过自己的错误输出作为输入推理时却必须面对。我在项目后期尝试了Scheduled Sampling——在训练中按一定概率用模型自己的输出替换真实token作为下一步输入概率从1.0逐渐降到0.1左右。这个方法对BLEU提升不算特别大但生成句子的流畅度和容错性确实好了一些。有兴趣可以试试注意概率衰减别太快否则训练会不稳定。4.2 损失函数、优化器和梯度裁剪损失直接对vocab维度做softmax交叉熵criterion nn.CrossEntropyLoss(ignore_indexword_map[PAD])ignore_index这个参数是必须设置的。因为batch内sequence按最长句子pad了pad位置不参与损失计算。我第一次没设置模型花了很多概率去预测PAD token结果生成的句子全是空洞后来查了半天才发现是这里的问题。for epoch in range(num_epochs): for i, (images, captions, lengths) in enumerate(train_loader): images images.to(device) captions captions.to(device) targets captions[:, 1:] inputs captions[:, :-1] features encoder(images) outputs decoder(features, inputs) # (batch, seq_len-1, vocab_size) loss criterion(outputs.reshape(-1, vocab_size), targets.reshape(-1)) optimizer.zero_grad() loss.backward() torch.nn.utils.clip_grad_norm_(decoder.parameters(), 0.5) optimizer.step()关于优化器我用的Adam学习率3e-4encoder部分单独用一个更小的学习率约3e-5。方法是为encoder和decoder分别建优化器参数组或者干脆建两个优化器。这样预训练的ResNet权重不会被破坏得太厉害同时又允许它稍微适应caption任务的分布。光是这样区分学习率就比统一学习率的效果好一截。梯度裁剪是RNN训练里最实用的防爆手段之一。LSTM在反向传播时可能梯度爆炸loss曲线突然从2跳到20基本就是炸了。加上clip之后训练稳定多了。max_norm我设置为0.5如果是小batch或不稳定时也可以放到1.0。4.3 Batch Size和学习率的关系我的GPU显存是24GB一开始用batch size64直接爆显存。降到32后稳定了loss曲线也平稳。但如果你只有8GB左右显存batch size只能到8到16这时loss曲线会像锯齿一样跳动。解决办法是用梯度累积每跑4个小batch再更新一次参数等效于batch size大了4倍。这个操作在PyTorch里很简单accumulation_steps 4 loss loss / accumulation_steps # 缩放保持等效损失尺度 loss.backward() if (i 1) % accumulation_steps 0: optimizer.step() optimizer.zero_grad()注意loss要先除以累积步数再backward否则学习率得重新调。4.4 推理解码Greedy还是Beam Search推理时训练用的Teacher Forcing就不存在了模型必须自己生成。最朴素的贪心解码是每步取概率最大的词但容易让模型陷入局部最优比如生成了a之后下一步只能沿着a的上下文往下走一旦走偏就很难拉回来。更稳的方案是Beam Search。思路是每一步保留top-k个候选序列k是beam size。比如beam size3模型每一步维护3个候选句子各自往下扩展最终选择整体log概率最高的序列。代码简述def beam_search_decode(decoder, features, word_map, beam_size3, max_len50): sos_idx word_map[SOS] eos_idx word_map[EOS] sequences [[[sos_idx], 0.0]] completed [] with torch.no_grad(): # 初始化LSTM状态 h0 decoder.init_h(features).unsqueeze(0) c0 decoder.init_c(features).unsqueeze(0) for step in range(max_len): all_candidates [] for seq, score in sequences: if seq[-1] eos_idx: completed.append((seq, score)) continue # 取序列最后一个词作为当前输入 last_word torch.tensor([[seq[-1]]], devicedevice) embeddings decoder.embed(last_word) lstm_out, (h, c) decoder.lstm(embeddings, (h0, c0)) logits decoder.fc(lstm_out.squeeze(1)) log_probs torch.log_softmax(logits, dim1) topk_log_probs, topk_indices torch.topk(log_probs, beam_size) for j in range(beam_size): new_seq seq [topk_indices[0, j].item()] new_score score topk_log_probs[0, j].item() all_candidates.append((new_seq, new_score)) # 保留当前步每个扩展后的h,c状态这部分实现需要小心 # 从all_candidates选取top beam_size sequences sorted(all_candidates, keylambda x: x[1], reverseTrue)[:beam_size] # 长度归一化处理并返回最优序列这里面有两个细节要特别注意。第一个是LSTM状态更新。由于beam search会维护k个候选每个候选的h、c状态都不一样不能共用同一份状态。上面简版代码只是为了说明框架实际实现需要把每个候选的h、c存起来扩展时各自传入。如果偷懒共用状态序列会错乱。第二个是长度惩罚。Beam Search的分数是log概率累加句子越长乘积项越多分数天然越低。如果不做处理模型倾向于生成短句。简单做法是最终分数除以句子长度或者加上Google那篇论文里用的长度惩罚项(5 len)^alpha / (5 1)^beta。我实际用下来简单除以长度已经能缓解短句倾向。beam size的选择3是性价比之王5稍好但推理时间明显增加超过5边际收益很小。批量推理时尤其要注意。5. 完整训练与验证的环境配置5.1 数据集组织与DataLoader实现我用的COCO数据集目录结构是这样组织的data/ train2014/ 000000000001.jpg ... val2014/ 000000000004.jpg ... annotations/ captions_train2014.json captions_val2014.json构建词表并生成一个统一的索引映射文件word_map.json。加载数据时每张图对应5条caption就生成5条训练样本。为了提升加载效率可以在第一次处理后把图像特征缓存到磁盘用统一的ResNet101提取好的2048维特征向量存成npy文件或者直接存h5。训练时直接从缓存读取省去反复前向ResNet的时间。我实测下来缓存特征后单epoch训练时间能缩短三分之二以上。DataLoader要写自定义collate函数按batch内caption长度降序排列方便后面处理。PackedSequence或者简单padding都行简单padding配合ignore_index已经够用。5.2 训练曲线观察和epoch设置以COCO训练集大小batch size32单卡V100级别GPU每个epoch跑10到15分钟。整个训练跑到12个epoch就足够。我记录了一下loss曲线的典型变化前2个epochloss从初始的8.5快速降到4左右模型在疯狂学词序和常见搭配第4到6个epochloss降到3左右开始能输出短而完整的句子比如A man standing in a kitchen到第10个epoch简单场景描述质量不错了。如果继续跑到15个epoch以上会发现loss还在缓慢下降但验证集上生成的句子开始出现重复词典型的过拟合信号。所以建议10到12个epoch就停或者用验证集BLEU做early stopping。5.3 BLEU和CIDEr评估评估我用COCO Caption官方评测脚本核心指标BLEU-4和CIDEr。BLEU衡量的是n-gram准确率对单词顺序和用词有一定要求CIDEr更偏向语义相关性和人工判断相关性更高。实际体验是网络上很多人只贴几个生成示例展示效果但真正判断模型好坏还是要跑这两个指标。我在验证集上跑出来的数值BLEU-4 0.24-0.27CIDEr 0.85-0.95。这个成绩放到论文里不够看但作为无注意力基础版已经说明模型学到了图像内容和文本之间的映射。如果你想要更高成绩下一步就是加空间注意力机制BLEU-4能提升三四个百分点。6. 常见问题排查与调试心得6.1 我踩过的那些坑问题现象可能原因解决方案loss完全不下降图像没做ImageNet归一化学习率过大或过小检查transform里的Normalize把学习率调到3e-4量级生成的句子全是PAD或很短CrossEntropyLoss没设ignore_index为PADcriterion nn.CrossEntropyLoss(ignore_indexword_map[ ])生成句子词序混乱Teacher Forcing下模型没学到合理的语言模型检查是否把输入和目标序列切反了增加LSTM层数或hidden_size生成内容与图像无关图像特征没有正确传入解码器检查init_h和init_c是否有有效梯度尝试把特征作为首步输入训练时loss突然飙升梯度爆炸clip_grad_norm_加0.5输出全是同一个词重复dropout过大或模型过小陷入循环状态调低dropout到0.3增大hidden_sizebeam search结果越搜越差长度惩罚缺失分数除以句子长度或加长度惩罚项加载保存的模型报维度错误用了DataParallel导致state_dict带module前缀保存时用model.module.state_dict()加载时按需去掉前缀6.2 数据加载慢的优化COCO图片读原始图做前向ResNet提取特征在数据加载上是个瓶颈。我的处理是把所有训练集图片用ResNet101先提一遍特征存成npy文件注意原始图像在训练时不用预训练模型了需要额外存一份统一裁剪后的图像或者直接用特征缓存。这样训练DataLoader只需要读特征和caption的token序列省去图像解码、resize、归一化这些操作。这里有个细节如果用缓存特征训练时数据增强里的随机裁剪、水平翻转就没法在图像层面做了。如果一定要做缓存的时候可以每个样本存9个裁剪版本四角中心水平翻转组合那在前向ResNet时做数据增强。实测下来图像层增强带来的提升和缓存带来的速度提升取舍因人而异。6.3 模型过拟合的判断和处理判断过拟合不能只看loss。我见过训练loss很低、但生成的句子完全不像样的情况这多半是模型记住了训练集的一些caption模板没学会举一反三。判断方法在训练集和验证集上同时生成句子如果训练集输出很流畅、验证集输出明显变差就是过拟合。处理方法加大Embedding和LSTM的dropout减小hidden_size或者用更大的词表过滤阈值避免罕见词干扰。还有一个小技巧验证时把beam size从3提升到5能稍微缓解验证集上的生成退化。6.4 推理阶段的速度优化beam search如果逐句推理会非常慢尤其在验证集上跑全部8000张图。优化手段是批量推理把多个样本的beam search并行每个样本维护beam_size个候选但多个样本合并成一个batch一起前向。实现逻辑比单样本复杂一些但速度提升非常明显。如果你不打算扣这个细节至少在做评测时别用逐句for循环跑一万张图能等死人。另一个推理加速技巧限制最大生成长度一般COCO的description平均句子长度在10到15个词之间max_len设50已经非常宽松了。max_len太大会让beam search在长句上浪费大量前向计算。7. 从基础版到进阶后续扩展方向7.1 加空间注意力机制基础版里图像特征被全局平均池化压成一个2048维向量所有空间信息都糊在一起了。生成cat这个词时模型不知道要重点看图的哪个区域。加注意力机制后解码每一步会计算一个空间注意力权重动态地从7x7的特征图上加权聚合视觉信息。注意力版本的大致改动点编码器输出不再全局池化保留(batch, 2048, 7, 7)解码器每个时间步把当前LSTM隐状态作为query和7x749个位置的特征做相似度计算softmax得到权重加权求和得到上下文向量再用这个向量预测下一个词。这个改动加上后BLEU-4能提升3到4个点而且你还能把注意力权重可视化直接看到模型在生成每个词时盯着图的哪个区域。这个可视化效果非常直观拿来做展示和调试都特别好用。7.2 换成Transformer解码器用带因果mask的自注意力替换LSTM再引入交叉注意力层让每个token可以attend到图像特征。目前做多模态的主流方案都长这样。但说实话基础版本直接跳到Transformer学习曲线有点陡。我的建议是先做注意力版Image Caption再上线Transformer这样你对attention这个概念的理解才是立体的。7.3 自监督预训练的衔接做完这个项目后面想往视觉语言预训练方向走就会发现所有基础都打牢了特征提取、序列生成、attention对齐、beam search解码。这些技能在现在的多模态大模型时代依然完全不过时。我自己做完基础版之后的体会是这个项目的价值不在于做出多高的分数而是它逼你把数据怎么串起来、模型怎么搭起来、loss怎么算、推理怎么解这整条链路想得明明白白。中间踩过的每一个坑都是后面做更大规模多模态任务时避免重踩的经验。最后分享一个小技巧如果你想快速验证模型代码有没有写对别一上来就跑整个COCO。先拿100张图、几百条caption跑1个epoch看loss有没有在下降、能不能过拟合这100张图。能过拟合说明代码逻辑是通的再上全量数据。这样排查bug的效率会高很多也省去了大量无意义的等待时间。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑