做街景字符识别这件事,我一开始想得挺简单:不就是拿一堆图片丢进CNN里分类嘛,训练完一验证就能收工。真等到TASK4这步,要把模型从头训练起来再验证清楚,才发现里面全是细节。街景字符识别,说白了就是输入一张街拍照片,让模型把照片上的门牌号数字串完整读出来。这里的关键在于,这个输出不是一个标签,而是一个长度不固定的数字序列,可能是一位数字,也可能是六位,模型必须自己决定输出多少位,每一位是什么。
这篇记录是实战项目里TASK4的复盘,内容集中在模型训练与验证这个环节。我这次采用“先定长、再训练、后验证”的思路,中间踩了不少坑,也总结了一套能直接抄走的流程。非常适合正在学习OCR、想搞懂序列识别模型怎么训练的人阅读;如果你是准备打图像类比赛的朋友,里面关于验证策略、学习率调整、数据划分的几个经验,基本可以迁移到大部分任务上。
1. 先把任务拆清楚:这一环节到底要解决什么
1.1 街景字符识别与普通图像分类不是一回事
街景字符识别属于OCR的细分场景,最常见的目标就是门牌号。门牌号的字符集合很小,只有0到9这十个数字,但长度是不固定的,通常1到6位,实际场景里还经常伴随倾斜、遮挡、光照不均、背景杂乱。这些特点决定了它不能直接套用普通图像分类的做法——分类任务输出的是一个固定的类别标签,而这里要输出的是一个字符序列。
理解这个区别很关键。如果把它当成“0到9十个类别的分类”来做,那你实际上预测的是整张图属于哪一个数字,完全没法回答“这张图里有几个数字”的问题。我见过有人一上来就搭了一个10分类的模型,跑完之后发现准确率低得离谱,原因就是任务定义错了。所以第一步,必须明确:我们要预测的东西是一个由数字组成的串,整体结构很重要。
1.2 训练与验证两个环节各要回答什么问题
训练环节做的是让模型的参数在训练数据上不断调整,让输出尽可能接近真实标签。但只盯着训练集,你永远不知道模型是真的学到了规律,还是把训练数据背下来了。很多新手第一次跑模型,看到训练准确率90%多就兴奋,结果一上验证集直接掉到50%,这就是典型的过拟合。
验证环节承担着“模拟考试”的作用。我的做法是,在每个训练epoch结束之后,把当前模型的参数固定住,用它去预测一批模型从未见过的验证样本,然后对比预测结果和真实标签。验证集的表现直接决定了三件事:要不要继续训练、该不该降低学习率、当前这个模型版本值不值得保存下来。本质上,验证环节是在回答“模型泛化能力怎么样”这个问题。
1.3 评估指标不能用单一的“准确率”糊弄过去
街景字符识别这个任务,正确率不能只看一个数。我这次用了三个指标来评估模型表现。
第一个是样本准确率,要求整串数字完全匹配才认为这个样本预测正确,这个指标最严格,也最接近真实使用体验。第二个是字符准确率,把所有样本的每个字符位单独拎出来,看预测对了多少个,这个指标可以帮我判断模型到底是一两个数字认不对,还是整串结构都没学明白。第三个是字符串编辑距离,它衡量预测串和真实串之间需要多少次插入、删除、替换才能变成一样,这个值越小越好。只看样本准确率的话,一个六位数的门牌号只要错一位,整个样本就判错,但这种“差一点”的预测其实暴露的是不同层次的问题,区分开来看,调优方向会清晰很多。
2. 数据和预处理:模型看到的每张图长什么样
2.1 数据划分的策略比想象中更重要
我这次用的是某个常见街景门牌号数据集,图片总量约6万张,每张图都标注了完整的数字串。做数据划分时,我没有直接按默认顺序切,而是用了分层抽样的思路,保证训练集、验证集、测试集里不同长度的数字串占比基本一致。
为什么要这么麻烦?因为如果验证集里六位数的样本特别多,而训练集里六位数很少,模型在验证集上的表现就会偏保守,你可能误以为模型泛化能力不行,实际只是数据分布没对齐。反过来也一样,如果验证集全是简单的一位数字,指标虚高,后面上线真实场景马上翻车。处理图像任务有个通用原则:数据切分的比例不重要,分布一致性才是重点。另外还要注意,验证集和测试集都必须彻底独立于训练集,不允许通过任何间接方式让模型提前“看到”验证集的信息。
2.2 预处理:从一张街拍图到网络张量
原始图片的尺寸五花八门,有的很宽,有的几乎正方形,不能直接喂给CNN。我统一把所有图片resize到64乘192的大小。为什么是64乘192而不是标准正方形64乘64?因为门牌号的数字串是横向展开的,保留一定宽高比能让模型在特征提取阶段不丢失太多空间信息。
接下来是灰度化、归一化和标签补齐。灰度化就是把三通道图像转成单通道,数字识别对颜色的依赖很低,这样做能减少计算量。归一化使用的是预训练模型的均值和标准差,这招在迁移学习中几乎是必选项,能让输入分布尽量贴近特征提取器原本适应的范围。最重要的一个操作是标签补齐:既然走定长方案,那就规定最大长度为6,所有不足6位的标签用空白符补到6位。这一步解决了数据尺寸不统一的问题,但同时也引出一个新问题——损失函数要处理好空白符的权重,后面第三章会详细讲。
2.3 数据增强:有些能用,有些是坑
数据增强是在训练时对图片做随机变换,相当于免费扩充训练集。我试了几种,最终保留的是随机裁剪、亮度抖动、高斯噪声。随机裁剪的幅度要控制好,因为数字串本身可能很靠近图片边缘,裁剪太狠会直接把数字截掉,模型学到的特征就废了。亮度抖动对街景场景非常友好,现实中早晚光照差异大,模型必须对亮度变化不敏感。高斯噪声则是模拟雨天或者老旧摄像头产生的颗粒感。
有一个增强操作我建议别用——水平翻转。数字6和9、2和5在翻转后语义会变化,虽然人眼可以结合上下文猜测,但模型在字符识别这种任务上没有强上下文纠错能力。对中文文本或者长文本OCR,翻转也许能试,但街景数字串这种目标,翻转带来的副作用大于收益。数据增强有一个红线:绝对不能改变图片对应的语义标签。翻转让“6”变成“9”却不改标签,模型学出来的就是错误映射。
3. 模型结构与损失函数怎么定
3.1 定长方案和不定长方案的取舍
既然任务里的数字串不超过6位,就有一个很省事的做法:把序列长度固定为6,短于6的用空白符补齐,这样模型每次预测6个位置,每个位置做一次11分类(0到9加上空白符),相当于把一个序列预测问题转化成一个多标签分类问题。
这个方案最大的优势是结构简单、训练稳定、推理速度也快,我TASK4最终采用的就是定长思路。它的问题在于人为假设了最大长度,万一遇到超过6位的门牌号,模型根本没法处理。另一条更通用的技术路线是用CNN提取特征,RNN建模序列关系,再用CTC损失函数对齐预测和标签。这种CRNN加CTC的方案支持动态长度输出,更接近真实OCR系统的做法,但训练难度也高一点,调参时对数据对齐的细节更敏感。我把CTC放在后续任务再试,当前这个环节的首要目标是先把训练与验证完整跑通。
3.2 骨干网络和分类头的具体设计
定长方案下,网络结构可以分为两部分:特征提取器和分类头。特征提取器我选了ResNet18,直接在预训练权重基础上改。为什么要预训练?街景门牌号样本虽然有几万张,但对于一个深层网络来说仍然不算充裕,用ImageNet上预训练好的参数做初始化,相当于让模型从一开始就具备基础纹理和边缘特征,训练收敛速度和最终准确率都会明显更好。
分类头这边,我把ResNet18最后的全连接层卸掉,从最后一个卷积特征图接一个全局平均池化,得到512维向量,再接两个全连接层:第一层256维,激活函数用ReLU,后面带一个概率为0.5的Dropout;第二层输出66维,reshape成6行11列。每一行代表一个字符位置,11列代表10个数字加一个空白。为什么中间要加Dropout?前几轮实验发现模型在验证集上的表现波动很大,加Dropout之后验证集准确率稳定提升了两个百分点,这个操作对抑制过拟合很有效。
3.3 损失函数不能只做简单的平均交叉熵
分类头输出的是6乘11的概率分布,自然想到用交叉熵损失。但如果对所有6个位置一视同仁求平均,会出现一个隐蔽的问题。定长方案里,短标签的后面几位都是空白,也就是说训练集里空白位非常多。如果每个位置等权,模型只要把所有位置都预测成空白,就能得到一个看似不低的损失和准确率,但实际没有任何识别能力。
我的做法是给每个位置设置不同权重,权重根据训练集中该位置非空的比例来计算。具体来说,第一位几乎总是有数字,权重大;最后一位经常是空白,权重就调小。这样模型会把“精力”集中在真正需要识别数字的位置上,验证集准确率比等权平均时高了不少。优化器我选的是Adam,初始学习率1e-3,配合余弦退火。Adam对学习率的敏感度低,前期可以快速下降损失,后期切到SGD微调了几轮,又涨了一点准确率,这个后面会细说。
4. 训练与验证的完整实现细节
4.1 超参数怎么定的,背后的理由是什么
最终记录的训练配置如下:输入尺寸64乘192,批量大小64,训练轮数30,初始学习率1e-3,权重衰减weight decay设为1e-4。批量大小选64是因为单卡显存刚好够用,更大批量会增加显存压力,而且街景数字识别这种任务,64的批量足以计算出相对稳定的梯度方向。权重衰减可以看作对参数变大的一种惩罚,相当于给过拟合加了一层保险。
学习率采用余弦退火策略,从1e-3逐步衰减到1e-5。余弦退火的好处是前中期维持一个相对大的学习率,让模型快速探索最优参数区域,后期缓慢平滑地靠近局部最优,比固定学习率更容易获得更好的收敛结果。整套配置不是一次到位的,我先用默认参数跑通,再根据验证集曲线反复调整了一轮才定下来。
4.2 训练循环里那些容易被忽略的步骤
写训练循环时,几个关键步骤的顺序马虎不得。每一次迭代里,先要用optimizer.zero_grad()清空上一步的梯度,然后模型前向得到预测,计算损失,再loss.backward()反传梯度,最后optimizer.step()更新参数。顺序一旦错乱,梯度就会累积叠加,训练几乎必然发散。
我在反传之后加了一步梯度裁剪,上限设为5.0。梯度裁剪的原理是:如果计算出来的梯度范数超过阈值,就按比例压缩到阈值以内。这一步在序列识别和OCR任务里非常实用,因为个别极端样本可能会产生特别大的梯度,把整个参数更新方向带偏。日志记录我用最直接的方式,每个epoch打印一次训练损失、训练集样本准确率、验证集样本准确率,数据量不大,肉眼就能看出来趋势有没有问题。
optimizer.zero_grad() logits = model(images) # 输出形状 [batch_size, 6, 11] loss = criterion(logits, labels) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 5.0) optimizer.step()4.3 验证逻辑和模型保存的最好实践
每个epoch结束之后,马上跑一次验证。验证阶段和训练阶段最大的区别是:不更新梯度、关闭Dropout、BatchNorm切换到评估模式。我见过太多人在这一步吃亏,忘了调用model.eval(),导致Dropout在推理时仍然随机丢弃一部分神经元,验证准确率忽高忽低,完全无法作为判断依据。
模型保存不能只盯着最后一次训练的结果。我每轮验证完都会比较当前验证集样本准确率是否比历史最好的更高,如果是,就覆盖保存一份best_model.pth。这个做法的价值在后续对比实验时体现得特别明显,因为最后一个epoch的模型往往是过拟合最严重的那个,而验证集表现最好的版本才是真正泛化厉害的那个。训练完之后我用测试集对最佳模型做了一次最终评估,这个测试集不参与任何训练和验证,得出的分数才是模型真实能力的基线。
5. 踩坑实录:几个能直接帮你避开的坑
5.1 验证集准确率远低于训练集,不单纯是过拟合
第一轮训练跑了20个epoch,训练集准确率已经94%,验证集始终只有78%上下。一开始我也以为是过拟合,后来去查验证集的数据分布,发现问题比预想复杂:验证集中六位数的比例明显比训练集高,而模型对长数字串的识别本来就差,这就放大了验证集上的差距。解决方法是重新做分层划分,让训练集、验证集、测试集在标签长度上的分布尽可能一致。重新划分之后,验证集准确率直接跳到了88%。这个教训说明,看到训练和验证差距大,先别急着骂模型,先检查数据分布。
5.2 损失一直在降,但字符准确率纹丝不动
有一段时间,训练损失每轮都在稳稳下降,可验证集的字符准确率卡在92%左右怎么都上不去。排查到最后,问题出在标签处理上:训练时把不足6位的标签补了空白符,但验证计算准确率时,没有把预测结果和真实标签的对齐关系处理好,导致带空白符的位全部被当成预测错误。这个bug非常隐蔽,因为损失和准确率两者使用的标签标准不一致,模型其实已经学得很好了,是评估逻辑本身出了问题。
5.3 训练中途损失变成NaN,先查数据再查学习率
训练到第7个epoch时,损失突然变成NaN,再往后梯度也乱了,整个模型直接废掉。逐层排查后,发现不是学习率太大,而是数据加载时混入了一张异常图片,里面有的像素值非常极端,归一化的时候出现除零错误。从此我在数据加载代码后面加了一个防御性检查,确认张量里没有NaN或者无穷大才送入模型。这类问题在图像任务里不算少见,尤其是没用现成高质量数据,而是自己爬来的图片时,一定要有这个意识。
5.4 Dropout和BatchNorm的坑:eval和train模式必须切换
验证时验证集准确率在89%到93%之间反复横跳,一开始还以为是数据增强太剧烈,后来发现是忘了在验证循环前调用model.eval()。训练模式下Dropout随机丢弃神经元,会让输出带有随机性,BatchNorm在训练时会用当前批次统计量而不是全局统计量,这两种情况叠加,结果就来回抖。只要在验证前调用model.eval(),验证结束后训练前调用model.train(),这个抖动就消失了。
6. 最终结果与几点心得
6.1 模型跑完后的最终成绩
整套流程跑完,最佳模型在验证集上的样本准确率91.5%,字符准确率96.2%,测试集上的样本准确率90.8%。这个数字对于街景门牌号识别来说已经具备实用价值。我还顺手打印了一批预测错误的结果,发现错得最多的集中在两个类别:长度特别长的数字串、以及图片里数字被树枝或车辆局部遮挡的样本。这说明模型的短板已经很明显了,后续改进方向也随之清晰。
6.2 下一步还能做哪些方向上的提升
如果还想继续涨点,有几条明确的路可以走。第一是把定长识别换成不定长的CRNN加CTC方案,让模型自己决定输出长度,不再受6位的限制,也更接近真实场景。第二是引入注意力机制,让模型识别到哪个位置就重点关注图片的哪一块区域,这对遮挡和复杂背景的情况会有帮助。第三是做检测和识别两阶段,先定位数字区域再做分类,把“读”的任务聚焦在已经被切出来的干净区域上。第四是做预测时增强,推理时对同一张图做多个位置裁剪,综合投票得到最终结果,通常能稳定带来一到两个百分点的提升。
我自己回看TASK4这轮任务,最大的体会是:别一上来就堆复杂模型,先把数据链路和验证体系搭扎实。这次将近92%的验证集准确率,靠的其实不是多么高端的网络结构,而是分层数据划分、位置加权损失、模型模式切换、最佳模型保存这些看似基础但决定成败的细节。训练日志一定要留全,轮数、学习率、训练准确率、验证准确率统统记下来,否则后面做对比实验的时候,你会发现自己完全说不清哪个改动起了作用。