【阿里云天池】实战:从零构建街景符号识别Baseline模型
1. 环境准备与数据初探你的第一个街景符号识别项目嘿朋友们今天咱们来点硬核又好玩的东西——从零开始手把手搭建一个街景符号识别的Baseline模型。我知道一听到“街景符号识别”、“深度学习模型”很多刚入门的朋友可能就有点发怵觉得这玩意儿是不是得数学博士才能玩别担心我刚开始接触的时候也是这么想的但实际做下来你会发现只要跟着清晰的步骤走这事儿就跟搭积木一样有章可循。咱们今天就用阿里云天池平台上的一个经典竞赛项目作为背景我会把我踩过的坑、试过的有效方法都揉碎了讲给你听保证你跟着做一遍就能跑通并且理解每一步背后的“为什么”。首先咱们得把“战场”准备好。这个项目本质上是一个计算机视觉任务具体来说是识别街景图片中的门牌号数字。天池提供了大量的街拍图片每张图片里可能包含一个或多个数字我们的任务就是把这些数字正确地识别并串起来。听起来是不是有点像OCR光学字符识别没错你可以把它理解为一个特定场景下的、更复杂的OCR问题。为什么说复杂呢因为街景图片的光照、角度、遮挡、字体大小都千变万化比扫描文档难多了。工欲善其事必先利其器。咱们的核心武器是PyTorch这是一个非常灵活且对初学者友好的深度学习框架。我强烈建议你在开始之前确保你的电脑或云服务器上已经安装好了Python3.7以上版本和PyTorch。如果你有NVIDIA的显卡并且安装了CUDA那训练速度会快很多。没有也没关系用CPU也能跑只是慢一点。安装命令很简单打开你的命令行终端输入pip install torch torchvision如果网络不太好可以加上清华的镜像源-i https://pypi.tuna.tsinghua.edu.cn/simple。除了PyTorch我们还需要一些好帮手比如处理图像的PIL现在叫Pillow、处理数据的numpy、读写json文件的库等等。别急这些我们都会在代码开头一次性导入。我个人的习惯是在项目开始前先在一个干净的Python环境里把这些依赖都装好避免后面出现各种诡异的版本冲突问题这可是我血与泪的教训。接下来咱们得看看数据长什么样。天池比赛通常会提供两个关键文件一个是存放所有训练图片的文件夹比如mchar_train另一个是描述每张图片对应标签的json文件比如mchar_train.json。你第一步要做的就是把数据下载下来解压然后花几分钟浏览一下。用几行代码看看图片数量、打开几张图片瞅瞅、再看看json文件里标签的格式。这个“数据初探”的步骤至关重要它能帮你建立对任务的直观感受。你会发现有的图片数字清晰居中有的则歪歪扭扭在角落有的甚至被树叶遮挡了一半。心里有数了才知道我们设计的模型要挑战的是什么。2. 数据读取与处理让模型“看懂”图片的第一步数据准备好了但电脑和模型可不认识一堆图片文件。我们的首要任务是把这些原始的图片和文本标签转换成模型能够理解和计算的格式。这个过程就像给模型准备“食材”食材处理得好不好直接决定了最后“菜”的味道。在PyTorch里我们主要通过两个核心组件来完成这件事Dataset和DataLoader。2.1 打造专属的数据集类DatasetDataset是一个抽象类你可以把它理解为一个数据集的“蓝图”或“协议”。我们需要根据自己数据的特点定制一个继承自Dataset的类。这个类主要干三件事初始化__init__、获取单个数据__getitem__和返回数据总数__len__。咱们先看代码骨架再一点点解释import os, glob, json from PIL import Image import numpy as np import torch from torch.utils.data.dataset import Dataset class SVHNDataset(Dataset): def __init__(self, img_path, img_label, transformNone): self.img_path img_path self.img_label img_label self.transform transform def __getitem__(self, index): # 1. 读取图片 img Image.open(self.img_path[index]).convert(RGB) # 2. 应用变换如果有 if self.transform is not None: img self.transform(img) # 3. 处理标签 lbl np.array(self.img_label[index], dtypenp.int) lbl list(lbl) (5 - len(lbl)) * [10] return img, torch.from_numpy(np.array(lbl[:5])) def __len__(self): return len(self.img_path)初始化 (__init__): 这里很简单就是把图片路径列表、对应的标签列表以及一个可选的“变换器”transform存起来以备后用。图片路径列表就是所有图片文件地址的集合标签列表是从json文件里解析出来的数字序列。核心__getitem__方法。这是最关键的一步它定义了“给你一个索引你如何返回对应的图片和标签”。读取图片Image.open(...).convert(RGB)这行代码用PIL库打开图片并强制转换为RGB三通道格式。这一步确保了无论原始图片是灰度图还是带透明通道我们都得到统一的3通道张量避免后续处理出错。图片变换transform是一系列图像预处理操作的组合。为什么需要它因为原始图片大小不一、颜色亮度不同直接扔给模型模型会很难学习。我们通过变换把图片“标准化”。常见的操作包括调整大小Resize、随机裁剪RandomCrop增加数据多样性、颜色抖动ColorJitter模拟不同光照、转为张量ToTensor把PIL图像或numpy数组变成PyTorch Tensor以及归一化Normalize用ImageNet的均值和标准差把像素值缩放到一个合理范围加速模型收敛。标签处理这是本任务的一个小技巧。街景门牌号长度不定可能是“1”也可能是“123”。但模型输出需要固定的结构。怎么办我们采用“定长序列”的思路。观察数据会发现绝大部分数字序列长度不超过5。于是我们统一把所有的标签都处理成长度为5的序列。具体做法是先把标签转成列表比如[1, 9]然后用数字10因为0-9是真实数字我们选一个不会冲突的在末尾填充直到长度为5。所以[1, 9]就变成了[1, 9, 10, 10, 10]。这样我们的模型任务就变成了同时预测5个位置上的数字每个位置都是一个11类0-9加上代表空的10的分类问题。这个思路非常巧妙把变长识别转化为了定长多任务分类。2.2 用DataLoader高效加载数据有了Dataset我们可以按索引取数据了但训练时我们通常需要按批次batch读取、打乱顺序、并且使用多进程来加速。DataLoader就是干这个的“数据搬运工”。from torchvision import transforms # 假设我们已经有了train_path图片路径列表和train_label标签列表 train_loader torch.utils.data.DataLoader( SVHNDataset(train_path, train_label, transforms.Compose([ transforms.Resize((64, 128)), transforms.RandomCrop((60, 120)), transforms.ColorJitter(0.3, 0.3, 0.2), transforms.RandomRotation(10), transforms.ToTensor(), transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]) ])), batch_size40, # 根据你的显卡内存调整越大通常训练越稳定但内存消耗也大 shuffleTrue, # 训练集一定要打乱防止模型学到数据顺序 num_workers4, # 用于数据加载的子进程数可以加快数据读取速度 pin_memoryTrue # 如果使用GPU设置为True可以加速数据从CPU到GPU的传输 )这里我详细说一下transforms.Compose里的每一步Resize((64, 128)): 先把所有图片缩放到一个统一的基础尺寸高64宽128。这是为了后续处理方便。RandomCrop((60, 120)): 随机裁剪出60x120的区域。这是数据增强Data Augmentation的关键一步。它相当于在每次训练时给模型看同一张图片的不同局部强迫模型不去死记硬背像素的位置而是学习更本质的特征能有效减轻过拟合。ColorJitter(0.3, 0.3, 0.2): 随机改变图片的亮度、对比度和饱和度。模拟现实世界中不同天气、光照条件下的街景。RandomRotation(10): 随机旋转-10度到10度之间。门牌号不总是水平的这个操作让模型对轻微旋转更鲁棒。ToTensor(): 转换格式并自动将像素值从[0, 255]缩放到[0.0, 1.0]。Normalize(...): 使用ImageNet数据集的均值和标准差进行归一化。这是一个通用做法因为我们的模型后面会用到ResNet是在ImageNet上预训练的使用相同的归一化参数能让模型更快适应。对于验证集或测试集的DataLoader处理方式稍有不同。验证/测试时不应该做带有随机性的数据增强如RandomCrop, ColorJitter, RandomRotation因为我们需要评估模型在确定数据上的稳定性能。通常只做Resize、ToTensor和Normalize。val_loader torch.utils.data.DataLoader( SVHNDataset(val_path, val_label, transforms.Compose([ transforms.Resize((60, 120)), # 保持和训练时裁剪后一致的尺寸 transforms.ToTensor(), transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]) ])), batch_size40, shuffleFalse, # 验证/测试不需要打乱 num_workers4, pin_memoryTrue )3. 模型构建巧用ResNet进行多任务学习数据管道搭好了接下来就是模型的核心部分。我们面对的任务是识别一个长度不定的数字序列。前面我们把标签处理成了5个定长的分类任务那么模型也需要有5个“头”来分别预测这5个位置。直接从头设计一个能处理这种任务的网络比较困难好在有“迁移学习”这把利器。我们可以用一个在大型图像数据集如ImageNet上预训练好的模型作为特征提取器然后针对我们的任务改造它的“头部”。这里我选择ResNet-18它深度适中效果不错训练起来对算力要求也相对友好。ResNet-18原本是用来做1000类图像分类的它的最后是一个全局平均池化层和一个全连接层。我们的改造思路是保留特征提取部分去掉原模型最后的全连接层分类头。添加我们自己的多任务头接上我们自己的5个并列的全连接层每个都输出11维对应0-9和空字符10。来看看代码实现import torchvision.models as models import torch.nn as nn class SVHN_Model1(nn.Module): def __init__(self): super(SVHN_Model1, self).__init__() # 加载预训练的ResNet-18模型 # 注意torchvision版本更新后加载预训练权重的参数名有变化 model_conv models.resnet18(weightsmodels.ResNet18_Weights.IMAGENET1K_V1) # 移除原始的平均池化层和全连接层 # 我们只保留特征提取部分卷积层 # 方法获取除最后一层外的所有子模块 modules list(model_conv.children())[:-2] # 注意这里去掉了最后两层自适应池化和全连接 # 重新定义我们自己的池化层。因为图片输入尺寸固定我们可以用自适应平均池化将特征图压缩为1x1 # 这样无论输入图片多大经过卷积后用自适应池化都能得到固定大小的特征向量 self.cnn nn.Sequential(*modules, nn.AdaptiveAvgPool2d((1, 1))) # ResNet-18最后一个卷积层的输出通道数是512 # 经过自适应平均池化(1,1)后特征维度变为 (batch_size, 512, 1, 1) # 我们需要将其展平为 (batch_size, 512) 才能输入全连接层 self.fc1 nn.Linear(512, 11) # 第一个字符的分类器 self.fc2 nn.Linear(512, 11) # 第二个字符的分类器 self.fc3 nn.Linear(512, 11) # 第三个字符的分类器 self.fc4 nn.Linear(512, 11) # 第四个字府的分类器 self.fc5 nn.Linear(512, 11) # 第五个字符的分类器 def forward(self, img): # 提取特征 feat self.cnn(img) # 输出形状: (batch_size, 512, 1, 1) # 展平特征去掉最后两个维度 feat feat.view(feat.shape[0], -1) # 输出形状: (batch_size, 512) # 5个“头”同时进行预测 c1 self.fc1(feat) c2 self.fc2(feat) c3 self.fc3(feat) c4 self.fc4(feat) c5 self.fc5(feat) return c1, c2, c3, c4, c5这里有几个关键点我想重点解释一下为什么用预训练模型ImageNet上有1400万张标注图片模型已经学会了识别边缘、纹理、形状等通用特征。这些特征对于识别数字也是非常有用的。使用预训练模型相当于站在巨人的肩膀上我们只需要用少量的街景数据去微调Fine-tune模型让它更适应我们的特定任务这比从头训练快得多效果也通常更好。自适应平均池化AdaptiveAvgPool2d这是一个非常实用的技巧。它允许你指定想要的输出尺寸比如(1,1)无论输入的特征图有多大它都会自动进行池化操作得到目标尺寸。这样我们就不必关心卷积层输出的特征图具体尺寸是多少总能得到一个固定长度的特征向量这里是512维方便后面接全连接层。多任务头我们创建了5个结构相同的全连接层nn.Linear。在forward函数中同一个特征向量feat被同时送入这5个分类器。这意味着模型学习到的图像特征是共享的但5个分类器各自学习专注于预测特定位置上的数字。这种设计既高效又符合直觉。模型定义好后我们可以实例化它并看看它的结构model SVHN_Model1() print(model) # 可以打印看看模型结构 # 如果有多张GPU可以用 model nn.DataParallel(model) 进行并行训练 if torch.cuda.is_available(): model model.cuda() # 将模型放到GPU上 print(Using GPU for training.) else: print(Using CPU for training, might be slow.)4. 训练与验证循环让模型从“学”到“考”模型和数据都准备好了现在进入激动人心的训练环节。训练深度学习模型就像教一个学生你给他看例题训练数据他做练习前向传播你批改并指出错误计算损失他根据错误调整自己的思路反向传播更新参数。这个过程循环往复直到学生模型的成绩准确率让你满意。4.1 定义损失函数和优化器首先我们需要定义“如何评判模型的错误”损失函数和“如何根据错误来调整模型”优化器。对于我们的多任务分类问题每个位置都是一个独立的11分类问题所以很自然地选择交叉熵损失CrossEntropyLoss。PyTorch的nn.CrossEntropyLoss非常智能它内部已经包含了Softmax操作所以我们模型的输出层不需要再额外加Softmax。优化器我们选择Adam它结合了动量和自适应学习率的优点在大多数情况下表现稳定不需要太多调参。学习率lr是一个关键超参数这里我们先设为0.001这是一个比较常用的初始值。import torch.optim as optim criterion nn.CrossEntropyLoss() # 损失函数 optimizer optim.Adam(model.parameters(), lr0.001) # 优化器 # 还可以添加学习率调度器比如每过一定epoch减少学习率 # scheduler optim.lr_scheduler.StepLR(optimizer, step_size5, gamma0.1)4.2 编写训练函数训练函数train负责完成一个epoch遍历一遍训练集的训练过程。def train(train_loader, model, criterion, optimizer, epoch): # 将模型设置为训练模式 # 这会启用Dropout、BatchNorm等层的训练行为 model.train() train_loss [] # 遍历数据加载器每次得到一个批次(batch)的数据 for batch_idx, (data, target) in enumerate(train_loader): # 将数据转移到GPU如果可用 if torch.cuda.is_available(): data, target data.cuda(), target.cuda() # 1. 前向传播模型根据输入数据做出预测 c0, c1, c2, c3, c4 model(data) # 2. 计算损失比较预测结果和真实标签的差距 # target的形状是 (batch_size, 5)每一行是一个样本的5位标签 # 我们分别计算5个位置上的损失然后求和或平均 loss criterion(c0, target[:, 0]) \ criterion(c1, target[:, 1]) \ criterion(c2, target[:, 2]) \ criterion(c3, target[:, 3]) \ criterion(c4, target[:, 4]) # 也可以求平均 loss (loss0loss1loss2loss3loss4) / 5.0 # 3. 反向传播计算损失关于模型参数的梯度 optimizer.zero_grad() # 在每次反向传播前必须将优化器中存储的上一次梯度清零 loss.backward() # 自动计算梯度 # 4. 优化步骤根据梯度更新模型参数 optimizer.step() # 记录当前批次的损失值 train_loss.append(loss.item()) # 可选每隔一定批次打印一次进度 if batch_idx % 50 0: print(fTrain Epoch: {epoch} [{batch_idx * len(data)}/{len(train_loader.dataset)} f({100. * batch_idx / len(train_loader):.0f}%)]\tLoss: {loss.item():.6f}) # 返回这个epoch的平均训练损失 return np.mean(train_loss)关键步骤解读model.train(): 这行代码很重要。它告诉模型中的某些特定层如Dropout、BatchNorm现在处于训练模式它们的行为会和评估模式model.eval()不同。比如Dropout会随机丢弃一些神经元以防止过拟合而BatchNorm会使用当前批次的统计量。optimizer.zero_grad(): PyTorch的梯度是累加的。如果不在每次反向传播前清零梯度会不断累积导致更新方向错误。这是一个常见的坑务必记住。loss.backward(): PyTorch的自动求导Autograd引擎在这里大显神通。它沿着计算图反向传播计算出损失函数关于每一个模型参数的梯度。optimizer.step(): 优化器根据计算出的梯度parameter.grad和设定的算法如Adam来更新所有参数。4.3 编写验证函数验证函数validate和训练函数结构类似但有三个核心区别模式切换使用model.eval()这会禁用Dropout、让BatchNorm使用训练阶段累积的全局统计量确保评估结果稳定。关闭梯度计算使用with torch.no_grad():上下文管理器。在验证时我们不需要计算梯度也不更新参数。关闭梯度计算可以节省大量内存和计算资源显著加快验证速度。不执行优化步骤没有optimizer.zero_grad(),loss.backward(),optimizer.step()。def validate(val_loader, model, criterion): model.eval() # 切换为评估模式 val_loss [] correct 0 # 可以增加计算准确率的逻辑 total 0 with torch.no_grad(): # 关键不计算梯度节省内存和计算 for data, target in val_loader: if torch.cuda.is_available(): data, target data.cuda(), target.cuda() c0, c1, c2, c3, c4 model(data) loss criterion(c0, target[:, 0]) \ criterion(c1, target[:, 1]) \ criterion(c2, target[:, 2]) \ criterion(c3, target[:, 3]) \ criterion(c4, target[:, 4]) val_loss.append(loss.item()) # 可选计算序列级别的准确率完全匹配才算对 # 获取每个位置预测的类别argmax得到概率最大的索引 _, pred0 torch.max(c0.data, 1) _, pred1 torch.max(c1.data, 1) _, pred2 torch.max(c2.data, 1) _, pred3 torch.max(c3.data, 1) _, pred4 torch.max(c4.data, 1) pred torch.stack([pred0, pred1, pred2, pred3, pred4], dim1) # 比较预测和真实标签注意处理填充的10 # 这里简化比较只有当所有非10位置都预测正确且预测的10位置数量一致才算完全正确 # 更精确的比较需要按原始长度比较此处略 # ... return np.mean(val_loss) #, accuracy 可以同时返回损失和准确率5. 主循环与模型保存开启迭代与保留成果我们把训练和验证组装起来形成一个完整的训练循环。通常我们会训练多个epoch完整遍历训练集的次数并在每个epoch结束后在验证集上评估模型同时保存表现最好的模型。import time best_loss float(inf) # 初始化一个非常大的数作为最佳损失 num_epochs 20 # 计划训练的轮数 for epoch in range(1, num_epochs 1): start_time time.time() # 训练一个epoch train_loss train(train_loader, model, criterion, optimizer, epoch) # 验证 val_loss validate(val_loader, model, criterion) epoch_time time.time() - start_time print(f\nEpoch: {epoch:03d} | Time: {epoch_time:.2f}s) print(f\tTrain Loss: {train_loss:.6f} | Val Loss: {val_loss:.6f}) # 如果验证损失比之前记录的最佳损失更低则保存模型 if val_loss best_loss: print(f\tValidation loss decreased ({best_loss:.6f} -- {val_loss:.6f}). Saving model...) best_loss val_loss # 保存模型的state_dict这是推荐的做法只保存模型参数不保存整个模型结构 torch.save(model.state_dict(), best_model.pt) # 也可以保存整个模型 torch.save(model, best_model_full.pth) patience_counter 0 # 如果用了早停策略这里可以重置计数器 else: print(f\tValidation loss did not improve from {best_loss:.6f}.) # patience_counter 1 # 早停逻辑如果连续多个epoch验证损失不下降则停止训练 # if patience_counter patience: # print(Early stopping triggered!) # break # 可以在每个epoch后调整学习率 # scheduler.step()这里有几个实用的技巧和注意事项模型保存torch.save(model.state_dict(), best_model.pt)是保存模型参数的推荐方式。state_dict是一个Python字典包含了模型所有可学习参数权重和偏置。这样保存的模型文件更小且加载时需要先实例化模型结构再加载参数灵活性更高。早停Early Stopping为了防止模型在训练集上过拟合我们可以在验证损失连续多个epoch不再下降时提前终止训练。代码中注释部分展示了简单的早停逻辑。patience是一个超参数比如设为5表示如果连续5个epoch验证损失都没创新低就停止。学习率调度可以使用torch.optim.lr_scheduler中的调度器比如StepLR每隔固定步数降低学习率或ReduceLROnPlateau当指标停止改善时降低学习率。这有助于训练后期更精细地调整参数找到更优的解。时间记录记录每个epoch的耗时有助于你预估整个训练过程需要多久方便安排时间。6. 预测、评估与结果分析检验模型的真实水平模型训练好了也保存了最佳参数最后一步就是用它来做真正的预测并看看效果到底怎么样。预测的逻辑和验证类似但目标不是计算损失而是得到具体的分类结果。6.1 编写预测函数预测函数需要处理测试集并输出每个样本在5个位置上的预测概率或类别。def predict(test_loader, model): model.eval() test_pred [] with torch.no_grad(): for data, _ in test_loader: # 测试集可能没有标签 if torch.cuda.is_available(): data data.cuda() c0, c1, c2, c3, c4 model(data) # 将5个分类器的输出在batch维度外拼接起来 # 每个分类器输出形状为 (batch_size, 11) # 拼接后形状为 (batch_size, 5*11) (batch_size, 55) # 这样每一行就代表一个样本的所有预测信息 output torch.cat([ c0.softmax(dim1), # 使用softmax得到概率分布 c1.softmax(dim1), c2.softmax(dim1), c3.softmax(dim1), c4.softmax(dim1) ], dim1) test_pred.append(output.cpu().numpy()) # 转移到CPU并转成numpy数组 # 将所有批次的预测结果在第一个维度样本维度堆叠起来 test_pred np.vstack(test_pred) return test_pred得到形状为(测试集样本数, 55)的预测结果后我们需要将其解码成具体的数字序列。解码规则是每11个数字为一组代表一个位置上的概率分布取概率最大的那个索引作为预测数字。索引0-9对应数字0-9索引10对应空字符。# 假设 test_pred 是预测函数返回的结果 predictions [] for pred in test_pred: # pred是一个55维的向量 # 分成5组每组11个值 pred_0_10 pred[0:11].argmax() # 第一个位置的预测 pred_11_21 pred[11:22].argmax() # 第二个位置的预测 pred_22_32 pred[22:33].argmax() # 第三个位置的预测 pred_33_43 pred[33:44].argmax() # 第四个位置的预测 pred_44_54 pred[44:55].argmax() # 第五个位置的预测 seq [pred_0_10, pred_11_21, pred_22_32, pred_33_43, pred_44_54] # 过滤掉空字符10并连接成字符串 seq_str .join([str(x) for x in seq if x ! 10]) predictions.append(seq_str)6.2 评估指标与结果分析对于这个任务常用的评估指标是序列准确率即预测出的整个数字序列与真实标签完全一致的样本所占的比例。这是一个比较严苛的指标因为只要有一个数字错了整个序列就算错。我们可以用验证集来估算模型的性能# val_label_list 是验证集原始标签列表例如 [123, 45, 6, ...] # val_pred_list 是模型在验证集上的预测结果列表格式同上 correct 0 total len(val_label_list) for true, pred in zip(val_label_list, val_pred_list): if true pred: correct 1 accuracy correct / total print(fValidation Sequence Accuracy: {accuracy:.4f} ({correct}/{total}))按照我们上面搭建的这个Baseline模型在训练10-15个epoch后序列准确率大概能达到50%-60%。这是一个不错的起点它意味着模型已经学会了识别大部分数字并能将它们大致组合起来。但显然离实用还有距离。为什么是这个结果模型容量ResNet-18作为基础特征提取器是有效的但可能对于更复杂、模糊的街景图片其特征提取能力还有提升空间。多任务设计我们让5个分类器共享同一个特征这对于相关性强的任务如相邻位置的数字是好的但也可能限制了每个分类器的独立判别能力。数据增强我们使用了一些基础的增强但可能还不够充分或者强度需要调整。标签处理定长5位并用10填充是一个简化策略。对于极少数长度大于5的样本这个策略会失效。同时模型需要额外学习“空字符”的概念。6.3 后续优化方向进阶思路如果你想在此基础上进一步提升准确率可以从以下几个方向尝试这也是算法竞赛中常见的提分手段更换更强的骨干网络将ResNet-18升级为ResNet-34、ResNet-50或者尝试EfficientNet、ConvNeXt等更现代的架构。更强的特征提取能力通常能直接带来性能提升。改进多任务头可以为每个分类任务设计更复杂的头部比如在共享特征后各自接上一个小型神经网络如两层全连接而不是简单的单个全连接层。引入注意力机制在特征层后加入注意力模块如SE Block, CBAM让模型学会关注图像中与数字相关的区域抑制背景干扰。更丰富的数据增强尝试MixUp、CutMix等高级数据增强技术或者使用AutoAugment策略搜索适合本任务的数据增强组合。调整损失函数除了简单的交叉熵损失求和可以尝试给不同位置的损失赋予不同的权重或者使用连接主义时间分类CTC损失来处理变长序列这可能是更优雅的解决方案。模型集成训练多个不同初始化或不同结构的模型将它们的预测结果进行投票或平均这是比赛中稳提升的“大招”。测试时增强TTA在预测时对同一张测试图片进行多种变换如翻转、旋转、缩放将多次预测的结果进行平均可以平滑掉单次预测的不确定性往往能提升1-2个点。我最初跑通这个Baseline时看到第一个epoch后损失开始下降最终准确率超过50%那种成就感是非常真实的。深度学习实践就是这样从一个简单可运行的起点开始然后通过不断迭代、实验、分析、改进一步步把模型性能推高。希望这个详细的指南能帮你顺利跨出第一步亲手构建出属于你的第一个街景符号识别模型。记住看懂和跑通之间隔着手敲一遍代码的距离遇到报错别怕那正是你理解系统在如何工作的最好时机。