LSTM图像描述实战:从CNN特征提取到Beam Search解码全流程解析
简介:图像描述(Image Captioning)是深度学习领域连接计算机视觉与自然语言处理的经典任务,其核心在于让模型理解图像内容并生成通顺的自然语言描述。这类任务通常采用Encoder-Decoder架构:CNN负责提取视觉特征,LSTM则作为序列生成器,将特征向量逐步解码为文字序列。LSTM通过门控机制有效缓解了传统RNN的梯度消失问题,在序列建模中表现出色,是理解条件语言生成与循环网络原理的绝佳切入点。掌握该技术不仅能应用于自动驾驶场景描述、辅助盲人理解图像等实际场景,还能为后续学习注意力机制和Transformer打下扎实基础。本文以一份可运行的LSTM图像描述项目为例,系统梳理从数据预处理、词表构建、模型训练到推理阶段Beam Search解码的完整链路,并总结常见调参与避坑经验,适合深度学习入门者通过实战吃透序列生成任务的核心机制。 这个项目我在本地跑过很多遍,从早期照着GitHub源码抄一遍能跑通就谢天谢地,到后来自己动手改结构、调参数、换数据集,中间踩过的坑基本可以写一本小册子。今天借着这个“使用LSTM生成图像描述-python源码.zip”的项目,把整条链路从数据准备、模型构建、训练推理到排查问题,完完整整梳理一遍。
这类项目本质上做的是**图像描述(Image Captioning)**任务:输入一张图片,模型输出一句自然语言描述。经典的实现路线就是encoder-decoder架构,CNN把图像变成特征向量,LSTM把这个特征向量翻译成文字序列。对刚接触深度学习的人来说,这个项目是绝佳的练手素材——它同时牵涉到计算机视觉和自然语言处理两条线,代码量又不会大到失控,而且训练完成后效果非常直观,模型到底学没学到东西,看一眼生成的句子就知道。
如果你正在找深度学习的入门实战项目,或者想理解序列生成任务的核心机制,这个项目源码值得你花一两个周末彻底吃透。
1. 先弄清楚图像描述到底在做什么
1.1 从一张图到一句话,任务的本质是条件语言生成
图像描述不是图像分类,也不是目标检测,它要输出的是一段完整的话。比如输入一张“草地上有两只狗在追逐”的图片,模型需要输出“Two dogs are running on the grass”这样的描述。这里面有两层意思:模型先要识别出图片里有什么(物体、动作、场景),然后还要用符合语法的自然语言把它们组织起来。
这就引出了条件语言生成的概念。我们不是在凭空生成一句话,而是给定图像特征作为条件,在这个条件下最大化目标句子的概率。用数学语言表达就是:假设图像特征为 ( v ),目标描述为单词序列 ( w_1, w_2, ..., w_T ),模型要最大化 ( P(w_1, ..., w_T | v) )。根据链式法则,这个联合概率可以拆解为每个时间步的条件概率乘积:
[ P(w_1, ..., w_T | v) = \prod_{t=1}^{T} P(w_t | w_1, ..., w_{t-1}, v) ]
LSTM在其中的角色就是负责建模这个条件概率。每一步它接收前一个单词的嵌入向量和当前隐藏状态,输出下一个单词的概率分布。而图像特征 ( v ) 则作为LSTM的初始状态输入,让整个生成过程从一开始就受到图像内容的约束。
这个设计思路用大白话讲就是:CNN先替LSTM看一眼图片,把关键信息浓缩成一个向量;LSTM拿到这个向量之后,开始一个词一个词地往外蹦。每蹦一个词,它都会结合之前已经生成的词来判断下一个词最可能是什么。
1.2 为什么用LSTM而不是别的结构
一定会有朋友问:为什么这里用LSTM,而不是直接用RNN,或者干脆上Transformer?
先说RNN的问题。传统RNN存在梯度消失和梯度爆炸的问题,当序列长度超过一定范围时,前面的信息很难传到后面。图像描述虽然句子不算特别长,但训练时如果序列超过20个词,普通RNN基本就学不动了。LSTM通过门控机制解决了这个问题。
LSTM内部有三个门:遗忘门决定丢弃多少旧信息,输入门决定写入多少新信息,输出门决定输出什么信息。这个结构让梯度可以沿着时间步更好地回传,所以LSTM能捕捉到更长距离的依赖关系。举个例子,如果句子是“A girl holding an umbrella is walking in the rain”,模型需要记住前面的“girl”对应的代词是“she”,而不是“he”,这种跨越多个词的信息关联,普通RNN处理起来很吃力,LSTM则相对轻松。
至于Transformer,它在机器翻译等领域确实比LSTM强,但在图像描述这个小规模任务上,Transformer需要的数据量和调参成本都更高,LSTM作为入门和理解序列生成的经典方案依然是更合适的选择。我个人觉得,先把LSTM这个经典结构吃透,再去看Transformer和Attention,会顺很多。
1.3 项目解压之后,源码结构长什么样
这类源码包解压之后的目录结构通常长这样:
image-captioning-lstm/ ├── model.py # 模型定义:EncoderCNN + DecoderLSTM ├── data_loader.py # 自定义Dataset和DataLoader ├── build_vocab.py # 构建词表 ├── train.py # 训练主流程 ├── predict.py # 推理脚本,加载模型生成描述 ├── utils.py # 工具函数,比如词表加载、图像预处理 ├── requirements.txt # 依赖清单 └── data/ # 数据集和标注文件拿到源码先别急着跑,我建议你按顺序做三件事:第一,看requirements.txt确认依赖版本;第二,打开model.py理解模型结构;第三,看train.py里的训练循环。把这三块理清楚,整个项目就等于吃透了一半。
2. 环境准备与数据预处理
2.1 依赖安装与版本匹配
我先说结论:这类项目的环境问题,十有八九出在版本不匹配上。我的建议是Python 3.8以上、PyTorch 1.10以上,这两个版本搭配最省心。
pip install torch torchvision pip install numpy pandas pillow nltk tqdm有几个关键的坑需要提前说。第一,如果你用Windows,PyTorch的安装命令要看清楚CPU版本还是GPU版本,GPU版本需要先装好CUDA和cuDNN,否则即使装了也调用不了显卡。第二,nltk这个库第一次运行的时候可能要下载punkt等数据文件,如果网络不好容易卡住,建议提前手动下载好放到对应目录。第三,torchvision的版本要和torch匹配,否则import会直接报错。
注意:如果你不打算用GPU训练,可以在代码里把设备设为cpu,小规模数据集比如Flickr8k,CPU训练也能跑,只是慢一些。当数据集比较大时,建议还是用GPU,否则训练时间会非常感人。
2.2 图像特征的提取,Encoder到底怎么干活
图像描述里的Encoder一般用预训练的CNN模型,常见的有VGG16、ResNet-50、Inception v3。在这个项目里,最常用的是ResNet-50,因为它在ImageNet上的表现好,而且ResNet的残差结构让特征提取更稳定。
具体做法是这样的:加载在ImageNet上预训练好的ResNet-50,去掉最后的全连接分类层,只保留前面的卷积部分。这样图片经过网络之后,输出的是一个2048维的特征向量。然后接一个线性层,把2048维映射到我们设定的embed_size,比如256维。映射结果作为LSTM的初始状态。
代码实现大概是这个思路:
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.resnet50(pretrained=True) # 冻结参数,不参与训练 for param in resnet.parameters(): param.requires_grad_(False) # 去掉最后的全连接层 modules = list(resnet.children())[:-1] self.resnet = nn.Sequential(*modules) # 将2048维特征映射到embed_size self.linear = nn.Linear(resnet.fc.in_features, embed_size) self.bn = nn.BatchNorm1d(embed_size, momentum=0.01) def forward(self, images): features = self.resnet(images) features = features.view(features.size(0), -1) features = self.linear(features) features = self.bn(features) return features这里有个要点值得展开说。requires_grad_(False)这行代码的作用是冻结ResNet的全部参数,训练的时候只更新线性层和BN层的参数。为什么要冻结?因为ImageNet预训练模型已经学到了非常丰富的视觉特征,对于图像描述任务来说,直接复用这些特征就够了,不需要在训练时再去调整上千万个参数。这样既能加快训练速度,又能防止过拟合。
BatchNorm1d在这里的作用是对映射后的特征做归一化,让特征分布更加稳定。momentum=0.01这个参数是经验值,实践中用默认值0.1也行,但0.01对小的batch size更友好。
我不建议用更大的embed_size,比如512。虽然理论上特征表示能力更强,但会让模型参数量变大。对于图像描述这个小任务来说,256维已经完全足够。
2.3 文本分词与词表构建
图像描述的数据集,比如MS COCO和Flickr8k,每条图片对应5句人工标注的描述。文本预处理的目标是:把所有描述文本转成模型能够处理的数字序列。
整个流程分五步:
第一步,读取所有标注文本,用nltk的tokenize按句分词。第二步,统计所有单词出现的频率,过滤掉出现次数少于5次的低频词。第三步,构建词表,给每个词分配一个索引。第四步,为每个句子添加起始符<start>和结束符<end>。第五步,将句子填充或截断到固定长度。
这里最关键的设计决策是词表大小。词表太小,比如只有1000个词,会导致很多词变成未知词<unk>,模型输出质量大幅下降;词表太大,比如超过2万,模型需要学习的参数会爆炸,而且很多长尾词在训练中出现的频率太低,根本学不好。我的经验是5000到10000之间比较合适,Flickr8k这个数据集5000左右就够用。
build_vocab.py的简化实现长这样:
import nltk from collections import Counter class Vocab: def __init__(self, token_list, min_count=5): counter = Counter(token_list) self.token_list = ['<pad>', '<start>', '<end>', '<unk>'] + [ token for token, cnt in counter.items() if cnt >= min_count ] self.token2idx = {token: idx for idx, token in enumerate(self.token_list)} self.idx2token = {idx: token for token, idx in self.token2idx.items()} self.vocab_size = len(self.token_list)注意<pad>、<start>、<end>、<unk>这四个特殊token要放在词表最前面,它们有固定的含义:<pad>用于填充短句让同一batch的序列等长,<start>告诉LSTM开始生成,<end>告诉LSTM句子结束,<unk>代替未登录词。
2.4 数据加载器:把图像和文本按batch喂进模型
PyTorch里要通过继承torch.utils.data.Dataset来定义数据集类,然后交给DataLoader去按batch加载。这个类要返回三样东西:预处理后的图像张量、描述文本的索引序列、以及描述的真实长度。
图像预处理要注意一个容易出错的地方:PyTorch模型的输入是(batch, channels, height, width),通道顺序是RGB,数值范围是0到1。但是原始图片读进来很多是HWC格式,直接用会报错。必须先用transforms.ToTensor()把HWC转成CHW、把像素值从0-255缩放到0-1,再用transforms.Normalize()按ImageNet的均值和标准差做标准化。
这个Normalize的均值和标准差是固定的:
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]) ])这三个均值0.485、0.456、0.406是ImageNet数据集的RGB通道均值,对应的标准差是0.229、0.224、0.225。这些数字是预训练模型的标准配置,不能改,一改就和不预训练模型的输入分布不一致了。我第一次跑这个项目时手滑把均值和标准差写反了,结果训练出来模型生成的全是乱码,排查了半天才找到原因。
在__getitem__里处理文本时,要做两件事:把句子转成索引序列,前面加<start>、后面加<end>。同时记录下原始长度,后面计算损失的时候要按真实长度做mask,避免padding部分参与计算。
3. 模型构建与训练细节
3.1 Decoder实现,从Embedding到LSTM再到词表映射
Decoder是整个项目的核心,也是代码里最值得反复看的部分。它的职责是:在给定图像特征的前提下,逐个预测下一个单词。
实现思路是这样的:LSTM每个时间步接收一个输入,这个输入是当前单词的嵌入向量。第一个时间步的输入是图像特征本身,后面每个时间步的输入是前一个单词的embedding。LSTM的输出经过一个线性层映射到词表大小,得到每个词的概率分布。
import torch import torch.nn as nn class DecoderLSTM(nn.Module): def __init__(self, embed_size, hidden_size, vocab_size, num_layers=1): super(DecoderLSTM, self).__init__() self.embed = nn.Embedding(vocab_size, embed_size) self.lstm = nn.LSTM(embed_size, hidden_size, num_layers, batch_first=True) self.linear = nn.Linear(hidden_size, vocab_size) def forward(self, features, captions): # captions: (batch, seq_len) embeddings = self.embed(captions[:, :-1]) # 将图像特征拼接到序列最前面 inputs = torch.cat((features.unsqueeze(1), embeddings), dim=1) outputs, _ = self.lstm(inputs) logits = self.linear(outputs) return logits这里面有一个很巧妙的设计:把图像特征当作序列的第一个元素,和单词的embedding拼接在一起,然后一次性喂给LSTM。这样LSTM第一个时间步看到的输入就是图像特征,输出的隐藏状态包含了图像信息,后续时间步就沿着这个基础继续生成。
nn.Embedding(vocab_size, embed_size)的作用是把单词索引映射成稠密向量。每个词对应一个可学习的向量,模型在训练过程中会自动调整这些向量,让语义相近的词在向量空间中距离更近。这就是词嵌入。
LSTM的参数里,batch_first=True意思是输入形状是(batch, seq_len, feature_dim),而不是默认的(seq_len, batch, feature_dim)。这个参数容易漏,一旦漏了,模型接收到的数据维度对不上,报错信息还特别难懂。
还有一个常见的变体是把图像特征直接作为LSTM的初始隐藏状态(h0, c0),而不是作为输入序列的第一个元素。两种方式都很常见,效果差别不大。源码里用的是哪种,你跑之前先确认一下。我个人的偏好是把图像特征作为初始状态,因为这样可以节省一个序列位置,序列长度可以少1,不过两者的实现差距确实很小。
3.2 Teacher Forcing 是什么,为什么训练时必须开
训练时的解码过程和推理时不一样。推理时,模型只能根据自己上一个时间步生成的词来预测下一个词;但训练时,我们使用了一种叫Teacher Forcing的技巧——直接使用真实的目标词作为当前时间步的输入,而不是使用模型自己预测的词。
为什么必须这么做?想象一下,训练初期模型完全是随机状态,如果让它自己生成的词作为输入,那第一个词是错的,第二个词就在错误的基础上继续猜,整个句子会越来越偏,模型根本无法收敛。Teacher Forcing相当于给模型一个“标准答案”,每一步都在正确的上下文里学习,梯度信号更明确,收敛速度快得多。
代码里的体现就是上面那个forward函数:直接把完整的captions序列(去掉最后一个词)作为输入,模型一次前向传播就能算出所有时间步的预测结果。这里不需要循环,LSTM的并行计算特性让整个序列的计算更快。
有些项目会用到scheduled sampling,就是在训练过程中以一定概率切换Teacher Forcing和模型自己生成的词,让模型逐渐适应推理时的场景。但在这个入门项目里,直接用全量的Teacher Forcing就够了,加了反而可能不稳定。
3.3 损失函数、优化器与超参配置
这个项目的损失函数用交叉熵损失,但有个细节处理要特别注意:预测的logits形状是(batch, seq_len, vocab_size),真实标签的形状是(batch, seq_len),直接计算交叉熵需要把它们reshape到合适的形状。
criterion = nn.CrossEntropyLoss(ignore_index=vocab['<pad>']) logits = model(images, captions) loss = criterion( logits.reshape(-1, vocab_size), captions[:, 1:].reshape(-1) )ignore_index=0是指忽略<pad>对应的标签,这样padding位置不参与损失计算。如果不设置这个参数,模型会在无意义的pad位置拼命学习输出<pad>,不仅浪费计算资源,还会干扰正常词的预测。
优化器一般选Adam,学习率设在1e-3。Adam在大多数任务上都是比较稳的选择,它融合了Momentum和RMSProp的优点,对学习率没那么敏感。我的经验是初始学习率1e-3、batch size 64到128,在Flickr8k上效果不错。
一个更进阶的做法是给Encoder和Decoder设置不同的学习率。Encoder的线性层学习率可以设小一点,比如1e-4,因为它的参数是从预训练模型迁移过来的,不需要大幅更新;Decoder的LSTM和Embedding层可以用1e-3的正常学习率。
超参数配置我汇总一下供参考:
| 参数 | 推荐值 | 说明 |
|---|---|---|
| embed_size | 256 | 词向量和图像特征的统一维度 |
| hidden_size | 512 | LSTM隐藏层维度 |
| num_layers | 1 | LSTM层数,数据量小不用太深 |
| learning_rate | 1e-3 | Adam默认推荐值 |
| batch_size | 64-128 | 取决于显存大小 |
| num_epochs | 20-30 | 小数据集20轮左右开始收敛 |
| beam_size | 3-5 | 推理时的beam search宽度 |
3.4 训练循环里容易被忽略的细节
训练循环本身看起来很简单,就是前向传播、算损失、反向传播、更新参数。但有几个细节处理不当,模型就训不好。
第一是梯度裁剪。LSTM在长序列上容易出现梯度爆炸,一旦梯度值变成inf或nan,模型就废了。加一行代码:
nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.0)这句话的作用是把所有参数的梯度范数裁剪到不超过5.0。我用这个项目测试过,不设梯度裁剪,大概四个epoch之后损失就可能开始震荡;设了之后,训练曲线明显平稳很多。
第二是学习率衰减。训练初期损失降得快,到了一定阶段会出现平台期。可以每隔几个epoch把学习率乘以一个衰减系数:
scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=5, gamma=0.8)StepLR的意思每训练5个epoch,学习率乘以0.8。这样可以让模型在训练后期用更小的步长做精细调整。
第三是保存最优模型。别只保存最后一轮的模型,训练过程中验证集的损失最小时往往就是泛化能力最好的时候。训练时记录最佳验证损失,当损失有提升时保存checkpoint。我的做法是同时保存模型参数和优化器状态,方便中断后恢复训练。
第四是数据增强。图像数据可以做随机裁剪、水平翻转、颜色抖动等操作,增加数据多样性,对防止过拟合帮助很大。但要注意,训练集增强和验证集增强要分开,验证集只做中心裁剪和归一化。
train_transform = transforms.Compose([ transforms.Resize((256, 256)), transforms.RandomCrop(224), transforms.RandomHorizontalFlip(), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) val_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]) ])训练时把256x256的图随机裁剪成224x224,相当于给模型看每个epoch不同位置的图,隐式扩充了数据集。推理时直接resize到224x224,不做随机裁剪,保证结果可复现。
4. 推理阶段,如何让模型生成像样的描述
4.1 贪心解码为什么不够好
训练完成后,推理阶段的目标是:给定一张新图片,让模型生成一句描述。最直接的办法是贪心解码:每个时间步都选择概率最大的那个词作为输出,然后把这个词作为下一个时间步的输入,直到输出<end>或者达到最大长度。
贪心解码的问题在于,它每一步都只考虑当前最优,却没有考虑全局最优。举个直观的例子:序列“A dog is running”和“A dog is running fast”,可能第一个词都是“A”,第二个词分别是“dog”和“cat”,其中“dog”的概率是0.4,“cat”的概率是0.6。贪心解码会选“cat”,但如果后面“running”和“dog”的搭配概率更高,整体来看“A dog is running”的概率反而更大。贪心算法在这个情况下就选错了。
所以就有了beam search(束搜索)。它的核心思想是每一步保留概率最高的前K个候选序列,而不是只保留一个。K就是beam size,通常取3到5。
4.2 Beam Search的实现思路
beam search的完整流程是这样的:
第一步,输入图像特征,用<start>作为第一个输入,得到第一个词的概率分布,保留概率最高的K个词。第二步,对每一个候选序列,把最后一个词作为输入继续前向传播,得到下一个词的概率分布。此时会有K×vocab_size个候选序列,从中选取概率最高的K个。第三步,重复第二步,直到所有序列都生成了<end>或者达到最大长度。第四步,从K个候选序列中选出得分最高的作为最终输出。
代码实现我需要给出简化版本:
def beam_search(decoder, features, vocab, beam_size=3, max_len=20): # features: (1, embed_size) vocab_size = len(vocab) # 候选序列用 (log_prob_sum, [token_idx_list], hidden_state, cell_state) start_token = vocab('<start>') end_token = vocab('<end>') beams = [(0.0, [start_token], None, None)] completed = [] for _ in range(max_len): new_beams = [] for log_prob_sum, seq, h, c in beams: if seq[-1] == end_token: completed.append((log_prob_sum, seq)) continue # 将最后一个token转成embedding last_token = torch.tensor([[seq[-1]]], device=device) embedded = decoder.embed(last_token) # LSTM forward得到新hidden和输出 ... # 取概率最高的beam_size个词 top_probs, top_idx = torch.topk(output_logits, beam_size) for i in range(beam_size): new_log_prob_sum = log_prob_sum + torch.log(top_probs[i]) new_seq = seq + [top_idx[i].item()] new_beams.append((new_log_prob_sum, new_seq, new_h, new_c)) # 全局排序,保留beam_size个最优 new_beams = sorted(new_beams, key=lambda x: x[0], reverse=True)[:beam_size] beams = new_beams # 若所有beam都在end就提前停止 if all(seq[-1] == end_token for _, seq, _, _ in beams): break if completed: best = sorted(completed, key=lambda x: x[0], reverse=True)[0] else: best = sorted(beams, key=lambda x: x[0], reverse=True)[0] return [vocab.idx2token[idx] for idx in best[1] if idx not in (start_token, end_token)]这里我用的是log概率之和作为序列得分,而不是概率的乘积。原因是概率值都在0到1之间,多个概率连乘的结果会非常小,比如20个0.5连乘大约是1e-6,数值上不稳定会下溢;而log可以把连乘变成累加,数值范围友好很多。beam search的得分比较都是在log空间进行的。
beam size的选择有讲究。beam size=1就是贪心解码,beam size越大搜索空间越大,理论上能找到更优的序列,但计算量也成倍增加。beam size=3时效果提升明显,beam size=5提升就开始减弱,beam size=10以上不仅慢,还可能引入一些奇怪的低频词组合。我实测在Flickr8k上beam size=3,BLEU-4评分比贪心解码高出大概5到8个百分点。
4.3 评估指标BLEU到底在衡量什么
模型生成的效果怎么量化?最常用的指标是BLEU(Bilingual Evaluation Understudy)。它的核心思想是:把模型生成的句子和人工标注的参考句子比较,看n-gram的词组匹配程度。
BLEU-1是看单个词匹配率,BLEU-2看相邻两个词的匹配率,以此类推。BLEU-4就是看连续四个词匹配率。四个词连续匹配上,说明模型生成的内容和人工标注高度一致。如果生成句子是“a dog is running in a park”,参考句子是“a dog is running in the park”,那么4-gram几乎完美匹配,BLEU-4得分就高。
不过BLEU有局限性。它只看词面匹配,不关注语义。两个句子语义相同但用词不同,BLEU得分会很低。所以现在很多论文还会用CIDEr、METEOR、ROUGE等指标综合评估。但在这个项目里,BLEU仍然是最直观、最常用的衡量标准。
我用Flickr8k训练出来的模型,验证集BLEU-4大概在0.20到0.25之间,这属于正常水平。如果你在MS COCO上训练,BLEU-4能到0.30以上,因为COCO数据量更大、描述更规范。
5. 实操中踩过的坑和排查技巧
5.1 常见问题速查表
训练和推理过程中会遇到很多问题,我把最典型的整理成一个表格,方便你快速定位:
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| Loss变成NaN | 学习率太大、梯度爆炸 | 降低学习率、开启梯度裁剪、检查输入数据是否有NaN |
| 训练时loss不下降 | 学习率太小、数据预处理错误 | 检查Normalize的均值和标准差、尝试增大学习率 |
生成的描述全是<unk> | 词表太小或数据量太少 | 降低min_count阈值、换大数据集、增加训练轮数 |
| 生成的句子很短就结束 | 模型欠拟合、<end>出现概率过高 | 增加训练轮数、调节LSTM的维度 |
| 同一个词重复出现 | 训练不充分、embed_size不够 | 增大embed_size、增加训练轮数、尝试beam search |
| 图像特征维度不匹配 | 预训练模型输出维度不一致 | 检查ResNet输出维度(ResNet-50是2048)与Linear层输入维度 |
| 显存OOM | batch size太大 | 降低batch size、使用梯度累积 |
| 训练很慢 | 使用了CPU训练或没有冻结Encoder | 冻结Encoder参数、优先用GPU |
| 加载预训练模型失败 | 网络问题、torchvision版本问题 | 手动下载权重文件放到对应目录 |
5.2 几个值得单独说的问题
第一个是损失在初期反而升高的问题。刚开始训练时,模型还没学会利用图像信息,生成的词几乎是随机的,如果某个batch里的句子特别长,loss可能比前一个batch还高。这不一定是模型出了问题,多观察几个batch再下结论。如果连续很多个epoch都不降,再考虑调学习率。
第二个是生成结果里常见的主语错误。比如图片里明明是“a girl”,模型输出了“a boy”。这种情况通常是训练数据里男女出现的频率不均衡,或者图像特征对细粒度物体区分度不够。一个简单有效的改进是换更强的预训练模型,比如从ResNet-50换成EfficientNet或ViT。
第三个是句子长度的问题。模型倾向于生成比较短的描述,因为短句子的概率通常更高。解决办法是在beam search里对短句做轻微惩罚。一种做法是给序列得分加上长度归一化:
score = log_prob_sum / len(seq)这个做法能一定程度上平衡长句和短句,让模型更愿意生成描述完整的句子。
5.3 如何快速验证你的模型有没有在学东西
训练到第5个epoch左右,可以随手拿一张训练集之外的图片跑一次推理,看看生成的句子前几个词是否和图片内容沾边。如果生成的句子完全和图片无关,比如给一张猫的图片输出“a dog playing with a ball”,说明模型可能没学到视觉和文本的对应关系,要检查图像特征是否真的注入了LSTM。
另外一个技巧是可视化LSTM的注意力(如果有Attention机制)。不过这个入门项目的经典版本没有Attention,但这反而是优点——先跑通基础版本,再自己动手加Attention,你会对这两种结构的效果差异有非常直观的体会。我也建议你后续可以往这个方向扩展:加上Attention模块,把LSTM的hidden_size增大,或者在Decoder里加一层Dropout,看看BLEU-4的分数能提升多少。
整个过程走下来,这个项目真正有价值的地方不只是让模型最终能生成像样的描述,而是你能亲手经历从数据到模型到评估的完整链路,理解每个环节的设计动机。我之前带过几个新人跑这个项目,一开始总想着直接调库、上Transformer,结果遇到问题就卡住。反而是老老实实把LSTM的每个门、beam search的每一步都搞明白,后面再接触更复杂的模型时思路一直很清楚。
本文还有配套的精品资源,点击获取
