☰
PyTorch手写数字识别实战:从99.2%到99.7%的优化路径与踩坑复盘
2026/10/13 17:07:01 网站建设 项目流程

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做标准化,但很多人直接抄了mean=0.1307, std=0.3081这两个数,却不知道它们是怎么来的。这两个值是整个MNIST训练集的全局像素均值和标准差,计算方式很简单:把所有训练图片的像素值除以255归一化到[0,1]区间,然后统计均值和标准差。但这里有个容易被忽略的点——如果你在训练时用了数据增强(比如随机旋转、平移),那么标准化应该在增强之后做,而不是之前。

我见过有人先把数据标准化了,然后再做随机旋转,结果旋转后的像素值分布完全变了,标准化等于白做。正确的顺序是:加载原始图像 → 转为Tensor → 数据增强 → 标准化 → 送入网络。在PyTorch里,用torchvision.transforms.Compose可以很方便地控制这个顺序:

from torchvision import transforms train_transform = transforms.Compose([ transforms.RandomAffine(degrees=10, 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_splits=5, shuffle=True, random_state=42) 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_workers=4通常就够了。pin_memory=True则会把数据加载到固定内存(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_size=128, shuffle=True, num_workers=4, pin_memory=True )

还有一个细节: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, padding=1) self.bn1 = nn.BatchNorm2d(32) self.conv2 = nn.Conv2d(32, 64, 3, padding=1) self.bn2 = nn.BatchNorm2d(64) self.conv3 = nn.Conv2d(64, 128, 3, padding=1) 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, stride=1): 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(), lr=0.001) scheduler = OneCycleLR( optimizer, max_lr=0.01, epochs=20, steps_per_epoch=len(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_smoothing=0.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(f'Early 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_aug=5): model.eval() predictions = [] with torch.no_grad(): for _ in range(n_aug): augmented = random_affine(image, degrees=5, translate=(0.05, 0.05)) output = model(augmented.unsqueeze(0)) predictions.append(F.softmax(output, dim=1)) return torch.mean(torch.stack(predictions), dim=0)

5.2 多模型集成:怎么选模型才能互补

模型集成是提升比赛成绩的“大杀器”,但关键是要选互补的模型,而不是简单堆叠相似的网络。我的经验是:选一个纯CNN、一个带残差连接的CNN、一个用不同初始化方式的CNN,三者的预测结果取平均或投票,通常比单个最好的模型高0.2%到0.4%。

集成的具体方式有两种:一种是硬投票,每个模型输出类别,取众数;另一种是软投票,每个模型输出概率,取平均。在MNIST上,软投票通常更好,因为它保留了模型的置信度信息。如果模型之间的性能差异较大,可以给每个模型加权,性能好的权重大一些。

def ensemble_predict(models, image, weights=None): 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, dim=1) return probs.argmax(dim=1)

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(seed=42): 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_location:

model = DigitCNN() model.load_state_dict(torch.load('best_model.pth', map_location='cpu'))

不加map_location会报“尝试在CPU上反序列化GPU张量”的错误。这个坑我在不同机器之间迁移模型时踩过好几次。

6.3 学习率太大导致loss变成nan的排查过程

训练过程中loss突然变成nan,是新手最常遇到的问题之一。原因通常有三个:学习率太大、数据中有异常值、网络中有除零操作。排查顺序应该是:先把学习率降低10倍,如果loss恢复正常,说明是学习率问题;如果还不行,检查数据预处理是否有误,比如是否忘了除以255;最后检查网络结构,看有没有可能导致数值不稳定的操作。

我在MNIST上遇到过一次loss变nan,最后发现是标准化时用了错误的均值方差,导致输入数据分布异常。所以每次开始训练前,打印一下输入数据的均值和方差,确认在合理范围内,是个好习惯。

7. 从99.2%到99.7%:我的优化路径复盘

回顾我自己的优化过程,准确率的提升并不是线性的,而是集中在几个关键节点上。基线模型(简单CNN+Adam+StepLR)大概在99.2%左右;加上数据增强和BN后到了99.4%;换成OneCycleLR和标签平滑后到了99.5%;最后加上残差连接和TTA,稳定在99.6%到99.7%之间。

每一步提升看起来都很小,但累积起来就是质的差别。更重要的是,这个过程中积累的经验可以迁移到其他图像分类任务上。比如数据增强的顺序、验证集的划分方式、学习率调度的选择,这些在CIFAR-10或更复杂的数据集上同样适用。

如果你现在正在参加Digit Recognizer比赛,我的建议是:先把基线跑通,确保整个流程没有bug;然后按照“数据→模型→训练策略→推理”的顺序逐步优化,每次只改一个变量,记录验证集的变化;最后再考虑集成和TTA。不要一上来就堆砌各种技巧,那样出了问题很难定位。

最后分享一个我常用的调试技巧:在训练前先用一个很小的子集(比如100张图片)跑几个epoch,确认模型能过拟合这个小数据集。如果连100张图片都过拟合不了,说明网络结构或训练流程有问题,这时候调超参数是没有意义的。这个技巧帮我省下了大量盲目调参的时间。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询