首页 / 资讯中心 / 文章详情

街景字符识别模型训练与验证:从准确率指标到调参避坑指南

街景字符识别模型训练与验证:从准确率指标到调参避坑指南 ★ FEATURED ARTICLE
做街景字符识别做了一大半很多人倒在第4步——模型训练与验证。倒不是训练跑不起来而是你能不能真正搞清楚“模型到底学好了没有”。这个任务和普通图像分类不一样输出的是不定长的数字序列哪怕只有一位数字判错整图就算错。我见过不少同学训练loss降得很漂亮验证准确率却原地踏步最后发现是验证集里用了不该用的数据增强或者空白类在损失函数里喧宾夺主。这篇学习记录把我在这个环节的完整做法、判断依据和踩坑点都整理出来适合已经具备基本深度学习框架使用经验、正在把街景字符识别从“模型能跑”推向“结果可信”的读者。1. 先想明白训练与验证在检验什么1.1 训练、验证、测试三个集合不能混着用这个任务里训练集用来更新参数验证集用来做模型选择和超参数调整测试集只在最后评估一次。很多人贪图方便反复拿验证集刷指标本质上是拿验证结果反推调参。刷多了验证集就带上了训练集的信息指标会虚高最后上测试集反而翻车。TASK4阶段默认使用验证集作为裁判但代码里一定要把三套数据分开。读图方式和预处理逻辑可以复用但路径列表必须独立管理。我的分割策略是这样from sklearn.model_selection import train_test_split lengths [len(label) for label in labels] train_paths, val_paths, train_labels, val_labels train_test_split( img_paths, labels, test_size0.15, random_state42, stratifylengths, )按字符长度做分层切分很关键。如果随机切验证集里可能出现五位数样本特别少的情况导致验证准确率波动很大调参时你根本分不清改动是真实有效还是只是噪声。1.2 序列输出带来的额外复杂度普通分类问题输出一个标签错了就错了一位街景字符识别输出一串长度不固定的字符必须先把输出变成固定长度。我用的方案是定义最大长度为5多出来的位置用“空白类”填充类别编号设为10。这样模型每次输出5个位置的独立分类结果每个位置都是11选1相当于把“识别整串数字”拆成“五个位置的分类问题”。这个方案有个天然代价模型不能显式建模位置之间的字符依赖。但作为入门流程它已经能拿到不错的准确率而且代码简单、好调试。这也是训练与验证环节最需要注意的地方你评估的不只是一个类别的正确率而是一整串字符是否完全对齐。哪怕四个位置都对只要一个位置错了整张图就算错这会让准确率看起来比直觉上“残酷”很多。2. 验证集划分与数据预处理评估的可靠性从这里开始2.1 划分方式要避免数据泄露从我的实验来看街景字符数据里的图片往往来自连续拍摄的街景同一拍摄点附近的图像会非常相似。如果只用普通随机切分相似图片很可能同时进入训练集和验证集。这种数据泄露的典型表现是验证准确率高得离谱训练loss也正常但你心里清楚模型没那么强。更稳妥的做法是给每张图按文件名或ID计算哈希值再按哈希值分桶划分训练集和验证集让来自同一拍摄点的图片基本落在同一侧。这样能有效避免“模型在验证集里偷偷看过长相相似的邻居图”。我通常是先把基础流程用简单分层随机划分跑通等模型稳定后再切换到哈希划分复测一遍。如果两次结果差距很大说明原本的验证集大概率是被污染了。2.2 读图、缩放与标签定长化统一图片尺寸时要注意街景中的数字基本都是横向排列的长宽比不是正方形。如果无脑把所有图都Resize到64x64细长数字会被压扁模型很难学到稳定的笔画特征。我的做法是先把高度缩放到64限制最长边在192以内不足的部分做padding更简单的做法是直接Resize到64x192。实测下来这种偏横向的分辨率比64x64更适合这个任务。训练阶段可以用轻微的随机旋转、平移、亮度对比度扰动来增强但不要用水平翻转数字翻转后含义就变了。验证阶段非常关键只做Resize和标准化任何随机增强都不能出现在验证管道里。标签定长化代码很简单MAX_LEN 5 PAD 10 def fixed_label(label): seq list(label[:MAX_LEN]) seq [PAD] * (MAX_LEN - len(seq)) return seq我第一次写数据集时把PAD值设成了0结果模型把第一位数字0和空位彻底混淆训练曲线一片混乱。所以这里强调一句空位类别要独立编号和数字0彻底分开否则后面所有训练与验证都是在错误定义下进行的。2.3 标签Tensor的一个隐蔽细节很多人在自定义Dataset里返回的标签是list或者numpy数组没有转换成long类型。CrossEntropyLoss对target有明确的类型要求必须是长整型。如果不转训练时要么直接报错要么某些版本下会按浮点类型处理出现难以排查的奇怪行为。我习惯在__getitem__里直接返回torch.tensor(seq, dtypetorch.long)让后续代码少一个隐患。这是一个很小的点但遇到训练不稳定时你未必会第一时间想到它。3. 模型结构选型不是网络越大越好是任务对齐才重要3.1 为什么从ResNet18起步就够街景字符识别在入门阶段可以看成“全局特征加位置分类”的任务。数字数量少、结构清晰并不需要特别深的主干网络。ResNet18这类残差结构参数少、不容易过拟合、在中等规模数据上收敛速度也快我把它作为默认基线。如果你一上来就用ResNet50这类更深的网络准确率并不一定线性提升训练时间和过拟合风险反而明显上升。先跑通基线再根据验证指标决定要不要升级主干这是更稳妥的做法。模型选型不是越贵越好而是和任务复杂度对齐。3.2 共享特征与多分类头的具体实现有的思路是为五个位置分别训练五个分类头特征提取网络共享最后一层分别接全连接。这个方案能做但我在实验里更倾向于让单个全连接层直接输出5乘以11个数值再reshape成5个位置的分类结果实现更简洁效果也一致class CharHead(nn.Module): def __init__(self, in_dim512, max_len5, num_classes11): super().__init__() self.max_len max_len self.num_classes num_classes self.fc nn.Linear(in_dim, max_len * num_classes) def forward(self, x): # x: (B, 512)来自主干网络池化后的特征 logits self.fc(x) return logits.view(-1, self.max_len, self.num_classes)这样拿到的logits形状是(B, 5, 11)最后一个维度就是每个位置在11个类别上的概率分布。为什么共享主干可行因为五个位置的字符都来自同一张街景图背景、光照、边缘信息高度一致。先抽取共享特征再分别判断每个位置比每个位置独立跑一遍主干网络省算力而且共享特征能互相补充。看到门牌背景有助于判断数字是否存在这就是共享特征的价值。3.3 预训练权重与输入尺寸的注意点如果要使用公开的预训练权重需要先确认预训练时用到的输入尺寸和图像标准化参数。直接把64x192的图喂给原本按224x224预训练的网络表面能跑但感受野和池化后的特征分布会和预训练设定不一致收敛会慢。我的习惯是按64x192输入同时使用预训练权重配套的标准化参数做归一化然后通过微调让网络适应新分辨率。如果数据量本身不小也可以去掉预训练权重从头训练节省加载时间最终效果差异不大。4. 训练配置细节损失函数、优化器与学习率怎么配合4.1 损失函数实现里最容易写错的维度损失函数用CrossEntropyLoss。当你面对(B, 5, 11)的logits和(B, 5)的标签时最常遇到的报错是“Expected target size [B, 11]”。原因是CrossEntropyLoss默认把输入第二维当作类别通道。对于三维输入它默认的布局是(N, C, d1)也就是类别维必须在中间。我们需要把logits从(B, 5, 11)变成(B, 11, 5)交换长度维和类别维logits model(images) # (B, 5, 11) logits logits.permute(0, 2, 1) # (B, 11, 5) loss F.cross_entropy(logits, labels) # labels: (B, 5)别小看这行permute。我遇到过训练loss一直降不下去的情况排查到最后就是这里维度没对齐PyTorch没有明显报错但实际梯度方向都是乱的。建议写完以后先打印logits.shape和labels.shape确认一遍再开训练。4.2 优化器与学习率策略的实测对比我试过SGD加Momentum和AdamW两条路线。SGD动量在数据集规范、学习率调得好的时候最终精度可能略高但需要手动试的学习率起点比较多对新手不友好。AdamW收敛稳定配合weight_decay不容易过拟合。这个任务我更推荐AdamW。学习率调度用CosineAnnealingLR初始学习率设在1e-3附近训练30个epoch后期自动降下来。如果发现前几个epoch的loss完全不动可以先跑3个epoch的warmup把学习率从很小的值慢慢升上去。一个参考配置如下配置项数值选择理由优化器AdamW收敛稳定对学习率不敏感初始学习率1e-3再大容易震荡再小收敛慢weight decay1e-4抑制过拟合对这类小数据集足够批次大小64显存允许时尽量大梯度更稳训练轮数30配合早停更合理学习率调度CosineAnnealingLR后期自动降低学习率收敛细节如果显存不够可以把batch size降到32用梯度累积两步效果和一次性跑64差不多。4.3 训练循环、保存与早停训练循环骨架先写出来best_acc 0.0 for epoch in range(epochs): model.train() for images, labels in train_loader: images, labels images.to(device), labels.to(device) logits model(images) logits logits.permute(0, 2, 1) loss criterion(logits, labels) optimizer.zero_grad() loss.backward() optimizer.step() scheduler.step() val_acc evaluate(model, val_loader) if val_acc best_acc: best_acc val_acc torch.save(model.state_dict(), best_model.pt)保存模型时我只认验证准确率不认训练loss。训练loss下降但验证不涨说明模型只是在背训练集保存下来也没意义。还可以加一个早停逻辑连续10个epoch验证准确率没有提升就停止训练。梯度裁剪可以作为兜底比如torch.nn.utils.clip_grad_norm_(model.parameters(), 5.0)防止偶发的梯度爆炸把loss打成NaN。5. 验证阶段怎么读结果指标、曲线与排错链路5.1 主指标用整串准确率辅助指标用逐位准确率评估代码要区分两个口径。整串准确率是最终目标解码出来的5个位置去掉空类后和真实标签完全一致才计数。逐位准确率则看每个位置单独的对错方便定位模型到底弱在哪一位。以我的统计经验模型经常出现第一位准确率高、后面位置明显偏低的情况因为数据里短序列居多后面几个位置大量是空类模型学得不够充分。只看整串准确率会一头雾水把逐位准确率画成柱状图问题就非常直观。评估函数核心如下def evaluate(model, val_loader): model.eval() correct 0 total 0 with torch.no_grad(): for images, labels in val_loader: logits model(images).permute(0, 2, 1) preds logits.argmax(dim-1) for pred, true in zip(preds, labels): p_text decode(pred) t_text decode(true) correct (p_text t_text) total 1 return correct / totaldecode要跳过类别10def decode(seq): return .join(str(x) for x in seq.tolist() if x ! PAD)这里有一个很隐蔽的坑如果用seq.cpu().numpy()代替tolist()在GPU张量上容易触发设备拷贝错误。统一用tolist()更省心。5.2 训练曲线怎么读三类典型信号我见过三类典型曲线。第一类训练loss一路下降验证loss走低后开始回升验证acc同步下降这是过拟合处理方向是增强数据、加大weight decay或提前停止。第二类训练loss和验证loss一起下降验证acc却不动多半是评估函数或解码逻辑写错了建议先打印几组预测结果和真实标签做对照。第三类loss曲线剧烈震荡验证acc忽高忽低大概率是学习率太大或batch太小。这些信号比单纯看“loss降到多低”更有判断价值。不要每改一次参数只看最终准确率把每个epoch的曲线存下来才能看清趋势。5.3 排查顺序建议先怀疑评估再怀疑训练当验证指标异常时我按这个顺序排查验证集标签是否正确验证增强是否泄漏评估解码有没有忽略空位损失函数维度是否正确模型输出是否正常收敛。这个顺序的核心思想是评估环节的问题会污染所有后续判断。先排除评估再折腾网络结构和学习率否则你改来改去都是在给一个错误打分的系统调参。6. 调参实录三个卡点与通用检查清单6.1 卡点一空白类占比太高模型总爱输出空串街景字符数据里短序列数量远多于长序列空白类的总样本占比很高。模型容易一开始就学到“偷懒”策略把多数字的图也预测成空白训练loss降得很快验证准确率却上不去。我当时通过统计标签长度分布发现这个问题后对损失里的各类别重新加权把空白类权重调低长短序列的准确率才逐渐平衡。另一个可选做法是对长度较长的样本做适度过采样效果也直接。6.2 卡点二验证集混入随机增强指标虚高又虚低有段时间我发现换一个随机种子验证准确率能波动好几个点。后来定位到问题我在验证管道里也放了随机裁剪验证图每次裁剪位置不同评估结果自然不稳定。这是非常典型的错误。训练和验证的预处理必须分开写验证管道只用Resize、标准化这类确定性操作任何随机增强都不要放进去。6.3 卡点三学习率从3e-3起步前几十个epoch反复震荡把初始学习率降回1e-3并加了3个epoch的线性warmup之后loss曲线明显平滑验证acc也在十个epoch内稳定上升。如果后续再遇到loss不降我会先检查有没有忘记切换model.train()和model.eval()。BatchNorm在训练和验证两种模式下的行为完全不同忘了切换会让验证结果失真。6.4 通用检查清单以下是我每次跑这个任务前都会过一遍的清单固定随机种子保证实验可复现训练/验证/测试路径分开管理所有随机增强只加在训练侧每次实验记录配置、指标和曲线保存最佳模型和最后若干个epoch的模型预测完成后随机抽16张图做可视化直接看错例比看数字直观。最后这个可视化步骤看起来笨却每次都能发现新问题。比如某个位置的数字反复被误识别成空白或者某种风格的字形完全没学到。可视化能把抽象的指标问题转化成具体的图像问题排查效率高很多。以我个人的实际体会收尾如果只能记住一件事那就是先把验证侧的标签、解码、评估函数写对再去碰主干网络、学习率这些“花活”。评估逻辑一旦有bug后面所有的调参经验都是在给错误打分先保证打分准再谈提升。这也是我从街景字符识别TASK4里学到最重要的一课。
阅读完成 · 觉得有帮助?
咨询建站