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

PyTorch手写数字识别实战:从99.2%到99.7%的优化路径与踩坑复盘

PyTorch手写数字识别实战:从99.2%到99.7%的优化路径与踩坑复盘 ★ FEATURED ARTICLE
1. 从一次“翻车”说起为什么手写数字识别值得反复折腾很多人第一次接触深度学习都是从手写数字识别开始的。MNIST这个数据集几乎成了行业里的“Hello World”网上随便一搜大把的教程告诉你加载数据、搭个卷积网络、跑几个epoch、准确率99%以上收工。但如果你真拿这个模型去参加Digit Recognizer这类比赛或者想把它部署到实际场景里就会发现事情远没有这么简单。我自己就踩过这个坑。当时用PyTorch搭了一个看起来很标准的CNN训练集准确率冲到99.5%信心满满地提交测试集预测结果结果排名直接掉到中游。问题出在哪后来一步步排查才发现数据预处理的方式、验证集的划分策略、甚至全连接层的初始化方法每一个细节都在悄悄影响最终成绩。Digit Recognizer比赛看起来简单但它其实是一个非常好的试验场——你可以在上面快速验证各种优化思路而且计算成本低试错代价小。这篇文章适合谁看如果你已经能用PyTorch跑通一个基础的图像分类流程但想进一步搞清楚“为什么我的模型在测试集上表现不如预期”“怎么把准确率从99.2%推到99.7%”这类问题那接下来的内容应该对你有用。我会从数据管道的构建开始一步步拆解Digit Recognizer比赛里真正影响成绩的关键环节包括那些教程里很少提但实战中特别重要的细节。2. 数据管道别急着搭网络先把这步做扎实2.1 标准化到底该用哪个均值和方差几乎所有教程都会告诉你要对MNIST做标准化但很多人直接抄了mean0.1307, std0.3081这两个数却不知道它们是怎么来的。这两个值是整个MNIST训练集的全局像素均值和标准差计算方式很简单把所有训练图片的像素值除以255归一化到[0,1]区间然后统计均值和标准差。但这里有个容易被忽略的点——如果你在训练时用了数据增强比如随机旋转、平移那么标准化应该在增强之后做而不是之前。我见过有人先把数据标准化了然后再做随机旋转结果旋转后的像素值分布完全变了标准化等于白做。正确的顺序是加载原始图像 → 转为Tensor → 数据增强 → 标准化 → 送入网络。在PyTorch里用torchvision.transforms.Compose可以很方便地控制这个顺序from torchvision import transforms train_transform transforms.Compose([ transforms.RandomAffine(degrees10, translate(0.1, 0.1), scale(0.9, 1.1)), transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ]) val_transform transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ])注意验证集和测试集不能用数据增强只能用和训练集相同的标准化参数。这一点看起来是常识但我在实际比赛中见过不少人为了“让验证集更鲁棒”也加了增强结果验证集准确率波动巨大完全没法用来做模型选择。2.2 验证集划分的坑别用随机划分大部分教程会教你用random_split把训练集按8:2随机划分成训练集和验证集。这种做法在MNIST上问题不大因为MNIST的类别分布非常均衡随机划分基本能保证验证集的类别比例和训练集一致。但如果你参加的是Digit Recognizer比赛测试集的分布可能和训练集有细微差异——比如某些数字的书写风格在测试集中出现得更多。更稳妥的做法是分层采样Stratified Sampling保证验证集中每个类别的比例和原始训练集完全一致。PyTorch本身没有直接提供分层划分的工具但可以用sklearn.model_selection.StratifiedKFold来实现from sklearn.model_selection import StratifiedKFold import numpy as np skf StratifiedKFold(n_splits5, shuffleTrue, random_state42) for train_idx, val_idx in skf.split(X_train, y_train): X_tr, X_val X_train[train_idx], X_train[val_idx] y_tr, y_val y_train[train_idx], y_train[val_idx] break # 取第一折作为验证集这样做的好处是当你尝试不同的模型或超参数时验证集的可比性更强。我自己的经验是在MNIST上分层划分和随机划分的最终测试成绩差异不大但在一些类别不均衡的数据集上分层划分能带来明显的提升。2.3 DataLoader的num_workers和pin_memory怎么设这两个参数看起来不起眼但对训练速度影响很大。num_workers控制数据加载的并行进程数设得太小会导致GPU等数据设得太大又会增加内存开销。在MNIST这种小数据集上num_workers4通常就够了。pin_memoryTrue则会把数据加载到固定内存pinned memory中加快CPU到GPU的传输速度。但这里有个坑如果你在Windows上跑代码num_workers大于0可能会导致程序卡死或报错因为Windows的进程启动方式和Linux不同。解决办法是把num_workers设为0或者把主训练逻辑放在if __name__ __main__:下面。我在Windows上调试时就被这个问题折腾过半小时最后发现是num_workers的锅。train_loader DataLoader( train_dataset, batch_size128, shuffleTrue, num_workers4, pin_memoryTrue )还有一个细节batch_size的选择。MNIST图片只有28x28模型参数量也不大所以可以用比较大的batch size。我试过64、128、256三个值最终128在训练速度和收敛稳定性之间平衡得最好。batch size太小会导致梯度噪声大太大又可能陷入尖锐极小值泛化性能下降。3. 模型结构从LeNet到轻量级ResNet的取舍3.1 为什么LeNet在比赛中不够用LeNet-5是MNIST上的经典模型结构简单训练快轻松达到99%的准确率。但如果你想在Digit Recognizer比赛中拿到前10%的成绩LeNet就不够看了。问题在于LeNet的感受野和特征提取能力有限它只有两个卷积层通道数分别是6和16对于手写数字中一些细微的笔画差异比如4和9、3和8的区分LeNet的特征表达能力不足。我做过一个对比实验同样的训练策略下LeNet在验证集上的准确率大约在99.1%左右而一个简单的4层CNN能到99.4%加了残差连接后能到99.6%。别小看这0.5个百分点在比赛中可能就是几百名的差距。3.2 一个适合MNIST的轻量级CNN该怎么设计我的建议是不要直接套用ResNet-18或VGG这种为ImageNet设计的网络它们的参数量对MNIST来说太大了容易过拟合而且训练慢。一个更合理的做法是设计一个专门针对28x28输入的小型网络核心思路是“足够深但不过宽”。我常用的一个结构是这样的第一个卷积层用32个3x3卷积核后面接BatchNorm和ReLU然后是一个2x2最大池化第二个卷积层用64个3x3卷积核同样接BN和ReLU再池化第三个卷积层用128个3x3卷积核池化后得到3x3x128的特征图最后接一个全局平均池化GAP和一个全连接层输出10类。import torch.nn as nn import torch.nn.functional as F class DigitCNN(nn.Module): def __init__(self): super().__init__() self.conv1 nn.Conv2d(1, 32, 3, padding1) self.bn1 nn.BatchNorm2d(32) self.conv2 nn.Conv2d(32, 64, 3, padding1) self.bn2 nn.BatchNorm2d(64) self.conv3 nn.Conv2d(64, 128, 3, padding1) self.bn3 nn.BatchNorm2d(128) self.gap nn.AdaptiveAvgPool2d(1) self.fc nn.Linear(128, 10) self.dropout nn.Dropout(0.3) def forward(self, x): x F.relu(self.bn1(self.conv1(x))) x F.max_pool2d(x, 2) x F.relu(self.bn2(self.conv2(x))) x F.max_pool2d(x, 2) x F.relu(self.bn3(self.conv3(x))) x self.gap(x) x x.view(x.size(0), -1) x self.dropout(x) x self.fc(x) return x这个网络参数量大约在10万左右训练一个epoch在单卡上只要几秒钟。关键设计点有三个一是用GAP代替Flatten大幅减少全连接层的参数量降低过拟合风险二是每个卷积层后都加BN加速收敛三是在GAP后加Dropout进一步正则化。3.3 残差连接在MNIST上到底有没有用残差连接Residual Connection是ResNet的核心思想它通过跳跃连接让梯度更容易传播从而训练更深的网络。在MNIST上网络本身不需要太深3到5层卷积就够了所以残差连接的收益没有在ImageNet上那么明显。但我实测下来加一个简单的残差块确实能带来0.1%到0.2%的提升而且训练更稳定。具体做法是在每个卷积块内部加一个跳跃连接如果输入输出通道数相同直接相加如果不同用1x1卷积调整通道数。不过要注意MNIST的输入是单通道第一个残差块的输入通道是1输出通道是32需要做通道变换。class ResidualBlock(nn.Module): def __init__(self, in_channels, out_channels, stride1): super().__init__() self.conv1 nn.Conv2d(in_channels, out_channels, 3, stride, 1) self.bn1 nn.BatchNorm2d(out_channels) self.conv2 nn.Conv2d(out_channels, out_channels, 3, 1, 1) self.bn2 nn.BatchNorm2d(out_channels) self.shortcut nn.Sequential() if stride ! 1 or in_channels ! out_channels: self.shortcut nn.Sequential( nn.Conv2d(in_channels, out_channels, 1, stride), nn.BatchNorm2d(out_channels) ) def forward(self, x): out F.relu(self.bn1(self.conv1(x))) out self.bn2(self.conv2(out)) out self.shortcut(x) return F.relu(out)但这里有个经验之谈如果你的网络只有3到4个卷积层残差连接带来的收益可能被额外的参数量和计算量抵消。我建议先跑一个不带残差的基线如果验证集准确率卡在99.3%上不去再尝试加残差块。4. 训练策略学习率、优化器和正则化的组合拳4.1 学习率调度OneCycleLR为什么比StepLR更稳学习率是训练中最难调的参数之一。很多人习惯用StepLR每过几个epoch把学习率乘以0.1。这种方法在MNIST上也能用但需要手动设置衰减节点调起来比较麻烦。我更推荐OneCycleLR它让学习率从一个小值线性上升到最大值然后再线性下降到接近零整个过程和训练epoch数绑定。OneCycleLR的好处是它自带“预热”和“退火”效果前期学习率小模型慢慢适应数据中期学习率大快速收敛后期学习率小精细调整。在MNIST上最大学习率设0.01左右比较合适配合Adam或SGD都能取得不错的效果。from torch.optim.lr_scheduler import OneCycleLR optimizer torch.optim.Adam(model.parameters(), lr0.001) scheduler OneCycleLR( optimizer, max_lr0.01, epochs20, steps_per_epochlen(train_loader) )注意scheduler.step()要在每个batch训练后调用而不是每个epoch后调用。这一点和StepLR不同写代码时容易搞混。4.2 标签平滑一个小改动带来的泛化提升标签平滑Label Smoothing是一种正则化技术它把硬标签如[0, 0, 1, 0, ...]变成软标签如[0.01, 0.01, 0.91, 0.01, ...]让模型不要对某个类别过于自信。在MNIST上标签平滑通常能带来0.1%到0.3%的准确率提升尤其是在模型已经接近收敛的时候。PyTorch的CrossEntropyLoss自带label_smoothing参数直接用就行criterion nn.CrossEntropyLoss(label_smoothing0.1)平滑系数一般设0.1设太大反而会欠拟合。我试过0.05、0.1、0.2三个值0.1在MNIST上表现最好。4.3 早停策略怎么判断模型真的不再提升了早停Early Stopping是防止过拟合的常用手段但关键问题是怎么判断模型“不再提升”如果只看验证集准确率可能会因为波动而误判。我的做法是同时监控验证集损失和准确率只有当两者连续多个epoch都不再改善时才停止训练。具体实现时可以设一个耐心值patience比如5个epoch。如果验证集损失连续5个epoch没有下降就停止训练并保存验证集损失最低的那个模型。注意保存的是验证集损失最低的模型而不是准确率最高的模型因为损失更能反映模型的整体置信度。best_val_loss float(inf) patience 5 counter 0 for epoch in range(epochs): train_loss train_one_epoch(model, train_loader, optimizer, criterion) val_loss, val_acc validate(model, val_loader, criterion) if val_loss best_val_loss: best_val_loss val_loss torch.save(model.state_dict(), best_model.pth) counter 0 else: counter 1 if counter patience: print(fEarly stopping at epoch {epoch}) break这里有个细节如果你用了OneCycleLR早停可能会打乱学习率调度。因为OneCycleLR是按总epoch数设计的提前停止意味着学习率还没降到最低。解决办法是把总epoch数设得稍微大一点比如30然后让早停在实际训练中决定何时停止。5. 推理与提交TTA和模型集成的实战细节5.1 测试时增强TTA到底值不值得做测试时增强Test Time Augmentation, TTA是指在推理阶段对同一张测试图片做多种变换如轻微旋转、平移然后对多个预测结果取平均。在Digit Recognizer比赛中TTA通常能带来0.05%到0.15%的提升代价是推理时间成倍增加。我的建议是如果比赛允许的推理时间充裕TTA值得做如果时间紧张优先保证模型本身的训练质量。TTA的变换幅度要小比如旋转±5度、平移±5%像素幅度太大会引入噪声。另外TTA的变换应该和训练时的数据增强保持一致否则分布不匹配反而会降低效果。def predict_with_tta(model, image, n_aug5): model.eval() predictions [] with torch.no_grad(): for _ in range(n_aug): augmented random_affine(image, degrees5, translate(0.05, 0.05)) output model(augmented.unsqueeze(0)) predictions.append(F.softmax(output, dim1)) return torch.mean(torch.stack(predictions), dim0)5.2 多模型集成怎么选模型才能互补模型集成是提升比赛成绩的“大杀器”但关键是要选互补的模型而不是简单堆叠相似的网络。我的经验是选一个纯CNN、一个带残差连接的CNN、一个用不同初始化方式的CNN三者的预测结果取平均或投票通常比单个最好的模型高0.2%到0.4%。集成的具体方式有两种一种是硬投票每个模型输出类别取众数另一种是软投票每个模型输出概率取平均。在MNIST上软投票通常更好因为它保留了模型的置信度信息。如果模型之间的性能差异较大可以给每个模型加权性能好的权重大一些。def ensemble_predict(models, image, weightsNone): if weights is None: weights [1.0 / len(models)] * len(models) probs torch.zeros(1, 10) for model, weight in zip(models, weights): model.eval() with torch.no_grad(): output model(image.unsqueeze(0)) probs weight * F.softmax(output, dim1) return probs.argmax(dim1)5.3 提交文件的格式检查别在最后一步翻车Digit Recognizer比赛的提交文件通常是CSV格式两列ImageId和Label。ImageId从1开始Label是0到9的整数。看起来很简单但我见过不少人因为格式问题被系统判为无效提交。常见的错误包括ImageId从0开始、Label是浮点数、行数不对、没有表头等。提交前一定要做三件事第一检查行数是否等于测试集样本数加1表头第二检查ImageId是否连续且从1开始第三检查Label是否都是0到9的整数。可以用pandas快速验证import pandas as pd submission pd.read_csv(submission.csv) assert len(submission) 28001, f行数不对: {len(submission)} assert submission[ImageId].min() 1, ImageId最小值不是1 assert submission[ImageId].max() 28000, ImageId最大值不是28000 assert submission[Label].between(0, 9).all(), Label超出范围 assert submission[Label].dtype int64, Label不是整数 print(格式检查通过)这些检查看起来琐碎但真到了比赛截止前半小时你绝对不想因为格式问题重新提交。6. 那些教程不会告诉你的踩坑记录6.1 随机种子没固定结果完全不可复现深度学习实验的复现性是个大问题。PyTorch、NumPy、Python内置的random模块都有自己的随机种子如果只固定了PyTorch的种子数据加载和增强的随机性仍然会导致结果波动。完整的种子固定应该包括import torch import numpy as np import random import os def set_seed(seed42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) os.environ[PYTHONHASHSEED] str(seed) torch.backends.cudnn.deterministic True torch.backends.cudnn.benchmark False注意cudnn.deterministic True会降低训练速度但能保证卷积操作的结果可复现。如果你追求极致速度可以设为False但结果会有微小波动。6.2 模型保存与加载state_dict还是整个模型PyTorch保存模型有两种方式保存整个模型对象torch.save(model, path)和只保存参数torch.save(model.state_dict(), path)。推荐用后者因为前者依赖模型类的定义如果代码结构变了加载时会报错。用state_dict保存加载时先实例化模型再load_state_dict。还有一个坑如果在GPU上训练并保存了模型在CPU上加载时需要加map_locationmodel DigitCNN() model.load_state_dict(torch.load(best_model.pth, map_locationcpu))不加map_location会报“尝试在CPU上反序列化GPU张量”的错误。这个坑我在不同机器之间迁移模型时踩过好几次。6.3 学习率太大导致loss变成nan的排查过程训练过程中loss突然变成nan是新手最常遇到的问题之一。原因通常有三个学习率太大、数据中有异常值、网络中有除零操作。排查顺序应该是先把学习率降低10倍如果loss恢复正常说明是学习率问题如果还不行检查数据预处理是否有误比如是否忘了除以255最后检查网络结构看有没有可能导致数值不稳定的操作。我在MNIST上遇到过一次loss变nan最后发现是标准化时用了错误的均值方差导致输入数据分布异常。所以每次开始训练前打印一下输入数据的均值和方差确认在合理范围内是个好习惯。7. 从99.2%到99.7%我的优化路径复盘回顾我自己的优化过程准确率的提升并不是线性的而是集中在几个关键节点上。基线模型简单CNNAdamStepLR大概在99.2%左右加上数据增强和BN后到了99.4%换成OneCycleLR和标签平滑后到了99.5%最后加上残差连接和TTA稳定在99.6%到99.7%之间。每一步提升看起来都很小但累积起来就是质的差别。更重要的是这个过程中积累的经验可以迁移到其他图像分类任务上。比如数据增强的顺序、验证集的划分方式、学习率调度的选择这些在CIFAR-10或更复杂的数据集上同样适用。如果你现在正在参加Digit Recognizer比赛我的建议是先把基线跑通确保整个流程没有bug然后按照“数据→模型→训练策略→推理”的顺序逐步优化每次只改一个变量记录验证集的变化最后再考虑集成和TTA。不要一上来就堆砌各种技巧那样出了问题很难定位。最后分享一个我常用的调试技巧在训练前先用一个很小的子集比如100张图片跑几个epoch确认模型能过拟合这个小数据集。如果连100张图片都过拟合不了说明网络结构或训练流程有问题这时候调超参数是没有意义的。这个技巧帮我省下了大量盲目调参的时间。
阅读完成 · 觉得有帮助?
咨询建站