ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

PyTorch手写数字识别全流程:MNIST、CNN训练评估与单图推理

PyTorch手写数字识别全流程:MNIST、CNN训练评估与单图推理

1. 手写数字识别为什么值得单独拎出来练一遍

手写数字识别这个题目,几乎是每个接触 PyTorch 的人绕不开的第一道坎。我见过不少人一上来就去搞目标检测、搞大模型微调,结果卡在张量维度对不上、损失函数不收敛这些最基础的地方,来回折腾几天就泄气了。而手写数字识别这套东西,数据是现成的、任务定义是清晰的、评价指标是直白的,你写错一个维度立刻报错,你调整一个参数立刻能在准确率上看到反馈。这种"输入-反馈"链路极短的练习,才是真正能把框架用法刻进肌肉记忆的方式。

这篇文章要讲的东西很具体:怎么用 PyTorch 把手写数字识别从数据读入一路做到模型评估和单张图片推理,中间每个环节为什么这么设计、参数怎么定、坑在哪里。我假设你至少知道张量是什么、知道梯度下降在干嘛,但没用 PyTorch 完整跑通过一个项目。如果你已经能熟练写训练循环,那这篇里关于归一化数值由来、卷积输出尺寸与参数量手算、混淆矩阵定位问题这几块,应该还是有点参考价值。

先把结论摆在这:同一份数据,一个两层全连接网络能做到 97% 到 98% 的测试准确率,一个结构很朴素的两层卷积网络能稳定在 99.2% 以上,差别不在参数量而在结构对图像先验的利用方式。后面我会把这两个模型都写出来,你照着跑一遍,感受一下这个差距是怎么来的。

2. 数据这一关,比你想的更容易出错

2.1 先搞清楚这份数据到底长什么样

手写数字数据集的结构非常简单:训练集六万张,测试集一万张,每张是 28×28 的灰度图,像素值范围 0 到 255,标签是 0 到 9 十个类别。类别分布大致均衡,每个数字在训练集里大约五千到七千张,没有严重的类别倾斜问题。这一点很重要,因为它意味着你不需要做类别加权、不需要做过采样,直接算准确率这个指标就是可信的。

很多人拿到数据第一反应是print一下形状就开跑,我建议你多花两分钟做这件事:把前若干张图连同标签一起可视化出来,或者至少把它们打印成 ASCII 字符画。你会看到有些数字写得很潦草,有些笔画断断续续,有些倾斜得厉害。这给你一个心理预期——模型不可能做到 100%,剩下那零点几个百分点基本都是这些人类自己都可能认错的样本。

另外提醒一句,这类经典数据集在 PyTorch 里有一键下载的封装,但它的默认下载地址有时候访问不稳定。我的习惯是先把原始文件下到本地某个固定目录,然后用dataset(root=..., download=False)去读,这样训练脚本就不会因为网络抖动挂掉。数据路径这种事,最好在脚本里用一个常量统一定义,别到处写相对路径,不然你在不同目录下执行脚本,报的错会让你怀疑人生。

2.2 transform 里藏着的第一个坑:归一化参数哪来的

图像读进来是 PIL 对象,模型要的是张量。ToTensor()这个变换干了两件事:把 HWC 布局转成 CHW,把 0-255 的整数映射到 0-1 的浮点数。注意这里是除以 255,也就是简单的线性缩放,不是标准化。

真正容易出问题的是紧接着的标准化。很多教程直接抄Normalize((0.1307,), (0.3081,))这两个数,但不说来历。这两个值就是这份数据集训练集的像素均值 0.1307 和标准差 0.3081(已经按除以 255 之后的口径算)。为什么值得较真?因为如果你自己换了数据集,或者自己做了数据增强导致分布变了,还硬套这两个数,模型虽然也能训,但收敛会慢一些。

想自己算一遍很简单,把训练集转成张量堆起来统计就行:

import torch from torchvision import datasets, transforms raw = datasets.MNIST(root="./data", train=True, download=False, transform=transforms.ToTensor()) loader = torch.utils.data.DataLoader(raw, batch_size=1000, shuffle=False) total, total_sq, count = 0.0, 0.0, 0 for imgs, _ in loader: total += imgs.sum().item() total_sq += (imgs ** 2).sum().item() count += imgs.numel() mean = total / count std = (total_sq / count - mean ** 2) ** 0.5 print(f"mean={mean:.4f}, std={std:.4f}")

这里用的是方差等于平方的期望减期望的平方这个公式,一次遍历就能算完,不用把六万张图全塞内存里。算出来的结果会非常接近前面那两个数,你可以自己验证一遍,验证过的东西用起来才踏实。

注意:验证集和测试集必须用训练集算出来的均值和标准差,不能各算各的。这是数据泄漏的一种隐蔽形式,虽然在这个任务上影响很小,但养成习惯比什么都重要。

2.3 DataLoader 的三个参数,决定了你训练得顺不顺

batch_size、shuffle、num_workers这三个参数,几乎决定了你训练过程的手感。

batch_size在这个任务上我一般用 64 或 128。太小(比如 8)梯度噪声大,训得慢;太大(比如 1024)在只有六万样本的情况下,一个 epoch 才六十步,收敛会不平滑,而且学习率得跟着调大。64 是一个很稳的默认值——每一步都有足够的样本把梯度噪声压下去,又保留了足够的更新次数。

shuffle=True这件事没有任何商量余地。训练集必须打乱,否则模型会先看到一大堆 0、再看到一大堆 1,梯度方向会剧烈摆动。测试集则不要打乱,保持顺序方便你后面把预测结果和原图对上。我踩过的坑是图省事把训练和测试共用一个 DataLoader 配置,结果评估时想抽第 k 张图看看长什么样,发现顺序全乱了。

num_workers在 Linux 或 macOS 上设成 4 或 8 能明显加速数据加载,但在 Windows 上经常出问题,轻则卡住,重则直接报进程相关的错。稳妥做法是设成 0 先用着,确认整个流程跑通之后再往上加,加到出问题就退回上一档。还有一个细节:num_workers > 0时如果自定义了数据集类,里面的对象不能是打开的文件句柄或数据库连接,因为会被多进程复制。这个坑在做自定义数据集时特别常见。

2.4 验证集要不要从训练集里切

原始数据集只给了训练和测试两部分。严格来说,你应该从训练集里切一部分出来作为验证集,用它来调超参、判断什么时候该停,测试集只在最后用一次。不切也能跑,但当你在测试集上反复试学习率、试网络结构,然后报告最好的那个结果时,这个数字是有乐观偏差的。

我的做法是从六万里切出五千做验证,剩下的五万五做训练。切的时候用固定随机种子,保证每次实验的划分一致,否则你比较两个模型的差异时,分不清是模型差异还是数据划分差异。代码上最省事的写法是生成一个索引列表再套Subset:

import torch from torch.utils.data import Subset g = torch.Generator().manual_seed(42) idx = torch.randperm(60000, generator=g).tolist() train_set = Subset(full_train, idx[:55000]) val_set = Subset(full_train, idx[55000:])

这里必须注意:Subset只是索引的包装,两边的 transform 是共享的。如果你给训练集加了随机增强而验证集不该加,那就得建两个数据集对象,各自带不同的 transform,而不是在同一个对象上套Subset。这是我在第一次做数据增强时栽的跟头——验证集上也做了随机旋转,导致验证准确率一直比测试低一截,找了半天才发现是评估时引入了随机性。

3. 网络结构:从全连接到卷积,差的不只是参数量

3.1 先搭一个能跑通的全连接基线

不要小看基线。一个结构简单、你能完全看懂的模型,是你排查后续所有问题的参照物。如果复杂模型训崩了,回头跑基线,基线正常就说明问题出在模型结构;基线也崩了,说明是数据或训练循环的问题。这个二分法能省掉大量时间。

全连接的写法很直接:输入 28×28 展平成 784 维,过一个隐藏层,再输出 10 维。

import torch.nn as nn class MLP(nn.Module): def __init__(self): super().__init__() self.net = nn.Sequential( nn.Flatten(), nn.Linear(28 * 28, 128), nn.ReLU(), nn.Linear(128, 10), ) def forward(self, x): return self.net(x)

参数量可以手算一下:第一层 784×128 个权重加 128 个偏置,等于 100480;第二层 128×10 加 10,等于 1290。总共十万出头,在这个任务上完全够用,训练集准确率能轻松到 99% 以上。

但它有个结构性的问题:展平这个操作把图像的空间邻接关系彻底丢掉了。对全连接层来说,第 1 个像素和第 2 个像素,和第 200 个像素,关系是等价的,都得单独学一个权重。而实际上相邻像素高度相关,远处的像素基本无关。这种"该共享的先验没共享,不该共享的强行全连接",导致它必须用大量参数去硬记模式,泛化能力自然上不去。这就是它在测试集上只能到 97%-98% 的根本原因,不是训得不够久。

3.2 卷积网络:把手算尺寸当成肌肉记忆

卷积网络的核心优势在于两点:局部连接和权重共享。一个 3×3 的卷积核在整张图上滑动,同一组权重被所有位置复用,参数量骤降;同时它天然只关注局部邻域,符合图像的特性。

我建议你在写卷积网络之前,先把输出尺寸和参数量的手算练熟,这样任何维度报错你都能一眼定位。公式是:输出边长 = (输入边长 + 2×padding − kernel_size) / stride + 1。以 28×28 输入为例,卷积核 3、padding 1、stride 1,输出还是 28×28;再接一个 2×2 的最大池化,变成 14×14;再来一轮同样的卷积加池化,变成 7×7。

下面这个结构是我常用的起点,两个卷积块加两层全连接:

class SimpleCNN(nn.Module): def __init__(self, num_classes=10): super().__init__() self.features = nn.Sequential( nn.Conv2d(1, 32, 3, padding=1), # 28x28 -> 28x28 nn.BatchNorm2d(32), nn.ReLU(inplace=True), nn.MaxPool2d(2), # 28x28 -> 14x14 nn.Conv2d(32, 64, 3, padding=1), # 14x14 -> 14x14 nn.BatchNorm2d(64), nn.ReLU(inplace=True), nn.MaxPool2d(2), # 14x14 -> 7x7 ) self.classifier = nn.Sequential( nn.Flatten(), nn.Linear(64 * 7 * 7, 128), nn.ReLU(inplace=True), nn.Dropout(0.5), nn.Linear(128, num_classes), ) def forward(self, x): return self.classifier(self.features(x))

把参数逐层算一遍,你会发现很有意思的对比。第一个卷积层:32×1×3×3 个权重加 32 个偏置,320 个。第二个卷积层:64×32×3×3 加 64,18496 个。第一层全连接:输入是 64×7×7 也就是 3136 维,到 128 维,3136×128 加 128,401536 个。第二层全连接 1290 个。合计约 42 万参数,比全连接基线多了四倍。

但重点不在总数,在分布。真正在做特征提取的卷积层只占了一万八千多个参数,超过 95% 的参数集中在那个把 3136 维压到 128 维的全连接层上。这说明什么?说明如果你还想优化,重点应该放在怎么减少全连接层的负担上——比如用全局平均池化直接把 64×7×7 压成 64 维,参数量能砍掉一个数量级,效果还不一定差。这个思路后面在更深的网络里会反复出现。

3.3 BatchNorm 和 Dropout 到底该放哪

这两个东西放错位置,效果会打折甚至反效果。

BatchNorm放在卷积之后、激活之前,这是主流做法,原因是它把卷积输出标准化到均值 0 方差 1,再交给 ReLU,能避免大量神经元被推到负半轴直接失活。有人喜欢放激活之后,也能训,但在浅层网络上收敛速度的差别是肉眼可见的。

Dropout只放在全连接层之间,不要放在卷积层后面。原因在于卷积层的参数是空间共享的,随机丢弃一部分通道相当于丢掉了整个特征图的信息,破坏性太强;而全连接层的每个神经元对应的是一组全局特征,丢一部分相当于做集成,是合理的正则化。至于丢弃比例,这个任务上 0.5 是安全值,如果你发现训练准确率上不去,先降到 0.2 或去掉,别一上来就怀疑结构有问题。

还有一点容易忽略的:BatchNorm在训练和评估时行为不同。训练时用当前 batch 的统计量,同时更新running mean 和 running var;评估时用累积的统计量。所以model.train()和model.eval()必须严格切换,尤其是你在验证阶段忘了切eval(),验证准确率会莫名其妙地波动——因为验证时 batch 内的样本统计量被用来归一化了,而且还在偷偷更新那些累积统计量。

4. 训练循环:短小但每一行都有讲究

4.1 损失函数和优化器的搭配逻辑

十分类任务的标配是交叉熵损失。这里有个高频误区:nn.CrossEntropyLoss内部已经包含了 softmax,所以模型最后那一层直接输出 10 个原始分数(logits),不要自己再接一个Softmax。如果你加了,相当于做了两次 softmax,梯度会被严重压扁,训起来特别慢,而且很难察觉,因为代码不报错,只是准确率上不去。同理,如果你用的是nn.NLLLoss,那才需要前面接log_softmax。这两者的关系搞清楚了,能省掉很多莫名其妙的调参时间。

优化器的选择上,Adam 和带动量的 SGD 都能到很好的效果,但它们的脾气不一样。Adam 用默认学习率 1e-3 基本就能直接收敛,对学习率不敏感,适合快速验证想法;带动量的 SGD 我一般设学习率 0.01、动量 0.9,前期收敛慢一点,但最终精度往往略高,而且泛化性能更稳。我个人的习惯是:写新结构时先用 Adam 跑通,确认结构没问题,再换成 SGD 加学习率调度去榨最后那零点几个百分点。

权重衰减也值得提一句。这个数据集本身规模不大、噪声不多,权重衰减设 1e-4 是个温和的正则化。设太大(比如 1e-2)会让模型欠拟合,训练准确率卡在 95% 左右上不去,很多人这时候会去调学习率、调结构,其实只要把权重衰减改小就解决了。

4.2 一个完整的训练与验证循环长什么样

下面这段是我实际在用的循环骨架,包含了训练和验证两个阶段,以及每个 epoch 后的指标记录:

import torch from torch.utils.data import DataLoader from torchvision import datasets, transforms device = torch.device("cuda" if torch.cuda.is_available() else "cpu") tf = transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)), ]) train_ds = datasets.MNIST("./data", train=True, download=False, transform=tf) test_ds = datasets.MNIST("./data", train=False, download=False, transform=tf) train_loader = DataLoader(train_ds, batch_size=64, shuffle=True, num_workers=0) test_loader = DataLoader(test_ds, batch_size=256, shuffle=False, num_workers=0) model = SimpleCNN().to(device) criterion = nn.CrossEntropyLoss() optimizer = torch.optim.Adam(model.parameters(), lr=1e-3, weight_decay=1e-4) scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=3, gamma=0.5) def run_epoch(loader, train=True): model.train() if train else model.eval() total_loss, correct, total = 0.0, 0, 0 ctx = torch.enable_grad() if train else torch.no_grad() with ctx: for imgs, labels in loader: imgs, labels = imgs.to(device), labels.to(device) outputs = model(imgs) loss = criterion(outputs, labels) if train: optimizer.zero_grad() loss.backward() optimizer.step() total_loss += loss.item() * labels.size(0) correct += (outputs.argmax(1) == labels).sum().item() total += labels.size(0) return total_loss / total, correct / total for epoch in range(1, 11): tr_loss, tr_acc = run_epoch(train_loader, train=True) te_loss, te_acc = run_epoch(test_loader, train=False) scheduler.step() print(f"epoch {epoch:02d} | train loss {tr_loss:.4f} acc {tr_acc:.4f} " f"| test loss {te_loss:.4f} acc {te_acc:.4f}")

有几处细节值得单独说。optimizer.zero_grad()必须在backward()之前调用,因为 PyTorch 默认是累加梯度的。忘了这一步,梯度会跨 batch 累加,表现出来的现象是训练初期还行,几轮之后 loss 突然爆掉或者剧烈震荡。这是新手最常见的错误之一,而且因为没有报错,特别难定位。

loss.item()乘以labels.size(0)再累加,是为了算整个 epoch 的加权平均损失。如果你直接把每个 batch 的 loss 加起来求平均,最后一个不满 batch 的会被高估权重,虽然在这个数据集上影响很小,但养成正确的写法习惯能避免以后在大数据集上踩坑。

验证阶段用torch.no_grad()关掉梯度记录,这不仅是省显存的问题,更重要的是防止验证时的前向传播把计算图挂在内存里不释放。我见过有人评估循环忘了加这个,跑到第八个 epoch 就 OOM,还以为是模型太大。

4.3 训练过程该盯哪些信号

不要只看最终准确率,过程中至少有四个信号值得盯。

第一是训练损失下降的形态。正常的曲线是前几轮快速下降,之后逐渐平缓,中间可能有一两次小的平台期。如果损失卡在 2.3 附近不动,那几乎可以确定是分类头没生效或者学习率太小到荒谬,因为 2.3 约等于 ln(10),就是随机猜的损失。如果损失变成 NaN,优先怀疑学习率过大或者输入数据里有异常值。

第二是训练准确率和验证准确率的相对关系。两者都低,是欠拟合,加容量、加轮数、调大学习率;训练准确率 99.9% 而验证只有 97%,是过拟合,加 dropout、加权重衰减、做数据增强。

第三是验证损失的变化。很多时候验证准确率还在缓慢上升,但验证损失已经开始抬头,这是过拟合的早期信号,比你看到准确率下降要早两三轮,是个很好的停止依据。

第四是每个 epoch 的耗时间。如果某一轮突然慢了好几倍,检查是不是有其他任务在抢机器资源,或者数据加载没跟上。num_workers设得太小、磁盘是机械盘,都会让 GPU 大量时间在等数据,利用率可能只有百分之二三十。

我自己的实际记录:这个两层卷积网络在单张消费级显卡上,一个 epoch 大约几秒钟,十轮之内测试准确率能到 99.2% 左右,二十轮加上学习率衰减能摸到 99.4%。如果你的结果明显低于这个区间,先别改结构,回头检查训练循环里的五个地方——数据打乱、zero_grad、train/eval 切换、设备一致性、损失函数有没有重复 softmax。

5. 评估与推理:训练完只是走完一半

5.1 光看准确率看不出问题在哪

99.2% 的准确率意味着每 1000 张测试图里还有 8 张是错的。这 8 张错在哪、错成了什么,准确率这个标量给不了你任何信息。想看明白,得靠混淆矩阵。

做混淆矩阵的代码不长,用 sklearn 一行就能出:

from sklearn.metrics import confusion_matrix, classification_report import numpy as np model.eval() all_preds, all_labels = [], [] with torch.no_grad(): for imgs, labels in test_loader: preds = model(imgs.to(device)).argmax(1).cpu() all_preds.append(preds) all_labels.append(labels) y_pred = torch.cat(all_preds).numpy() y_true = torch.cat(all_labels).numpy() cm = confusion_matrix(y_true, y_pred) print(classification_report(y_true, y_pred, digits=4))

跑出来你会看到一些规律性的东西,这是这个任务的经典现象:

易混组合主要原因
4 与 9上半部分开口闭合、竖笔出头与否高度相似
3 与 5上部弧线与横笔的连接方式接近
7 与 1顶部的横笔写得短或长时容易混淆
8 与 3左侧竖笔断开时结构接近

看到这些之后,你再去看那些错分的原图,会发现大部分确实是书写极度潦草或者笔画异形的样本。这时候你就知道,剩下那零点几个点不是靠加一层卷积能解决的,数据本身的歧义就是天花板。

顺带一说,classification_report里的每一类精确率和召回率也值得看。如果某一类的召回率明显低,说明模型对这一类偏保守,可能是训练样本里这一类偏少,或者这一类和其他类的特征重叠严重。这比一个总体准确率有用得多。

5.2 模型保存:三种方式,别选错

PyTorch 存模型有好几种方式,选错了以后加载会很难受。

最推荐的是只存state_dict,也就是参数字典:

torch.save(model.state_dict(), "cnn_mnist.pth") # 加载时先建同结构模型,再灌参数 model2 = SimpleCNN().to(device) model2.load_state_dict(torch.load("cnn_mnist.pth", map_location=device)) model2.eval()

这种方式的优点是不依赖你原来的类定义位置。缺点是加载时必须先有同样的网络结构代码,键名对不上会直接报错,缺一个键多一个键都会提示。

第二种是存整个模型对象torch.save(model, path),用torch.load直接拿回来。看着方便,实际上很坑:它依赖你保存时那个类的完整路径,一旦你重构了代码、改了类名或者移动了文件位置,加载就会失败。跨项目复用基本没戏。

第三种是存一个检查点字典,把模型参数、优化器状态、当前 epoch 数、最佳准确率都塞进去。这个在需要中断续训的场景下是必须的,因为优化器自己也有状态(比如 Adam 的一阶二阶矩估计),只存模型参数重开训练,优化器状态清零会让损失曲线出现一个明显的抖动。

提示:torch.load时带上map_location,否则在一个只有 CPU 的机器上加载 GPU 训出来的参数会报错。这个小参数能省掉很多"明明存了却加载不了"的困惑。

5.3 单张图片从磁盘到预测结果

模型训完,真正的使用场景往往是:给你一张图,告诉我这是几。这个流程和训练时的批量处理不一样,坑也多。

第一个坑是通道维度。训练时输入是 N×1×28×28 的四维张量,单张图读进来是 28×28,ToTensor()之后是 1×28×28 的三维,必须用unsqueeze(0)补一个 batch 维度凑成 1×1×28×28。忘了这一步会报维度不匹配。

第二个坑是色彩模式。如果你用自己找的图片,很可能是 RGB 三通道,而模型要的是单通道灰度。转成L模式这一步不能省,否则通道数对不上,卷积层输入维度就错了。

第三个坑是尺寸。模型是全卷积加池化的结构,对输入尺寸有一定容忍度,但最后的全连接层是写死 64×7×7 的,输入尺寸变了就会在展平那里报错。所以要么在一开始就 resize 到 28×28,要么把分类头改成自适应池化。稳妥起见,直接 resize。

第四个坑是背景。训练数据是黑底白字,如果你的图片是白底黑字,模型准确率会掉得很厉害,因为像素分布完全反了。这时候需要做反色处理,最简单的办法是取反:255 - img。

完整的推理函数大概长这样:

from PIL import Image def predict(path, model, device): img = Image.open(path).convert("L").resize((28, 28)) x = tf(img).unsqueeze(0).to(device) # tf 必须和训练时完全一致 model.eval() with torch.no_grad(): logits = model(x) prob = torch.softmax(logits, dim=1) conf, pred = prob.max(dim=1) return pred.item(), conf.item()

这里最关键的一句是注释那行:预处理流程必须和训练时一模一样。有人训练时用了 Normalize,推理时只用了 ToTensor,模型输出会明显变差但又不至于完全失效,这种"看起来能跑但结果不对"的问题是最难查的。我的做法是把 transform 定义成一个全局常量,训练和推理都引用同一个对象,从源头上杜绝不一致。

另外返回一个置信度是很有必要的。低于 0.5 的预测结果,基本可以认为模型在猜,实际使用中这一类应该标出来人工复核,而不是直接给出答案。

6. 常见问题与排查速查

6.1 训练不收敛的排查顺序

遇到 loss 不动、准确率卡在 10% 上下,按这个顺序查,基本能覆盖九成情况。

现象最可能原因处理方式
loss 恒为 2.3 左右输出层被 softmax 压过,或标签类型不对去掉多余的 softmax,确认标签是 long 类型
loss 先降后爆学习率过大降到 1e-4 重试,或加梯度裁剪
loss 变成 NaN输入含 NaN,或学习率过大检查数据,降到 1e-5 试跑两个 batch
准确率长期 10%分类头维度写错,或标签被错位打印一批 logits 和 labels 对照看
训练准确率高但验证极低忘了切 eval 模式,或缺正则化检查模式切换,加 dropout
每轮耗时异常长数据加载瓶颈提高 num_workers,或把数据预加载到内存

第一条特别值得展开说。"标签类型必须是 long",这是因为交叉熵损失内部要做索引查表,索引必须是整型。如果你从某个地方读进来的标签是 float,会报类型错。而"分类头维度写错"这个更隐蔽——比如你把输出改成 11 类,代码照跑不误,只是有一类永远预测不到,准确率上限就被锁死了。

设备不一致也是高频问题。模型在 GPU 上,数据还在 CPU 上,第一步前向就报错。我的习惯是在脚本最上面定义一次device,然后所有.to(device)都引用它,不写死"cuda"。这样换机器时不用改代码。

6.2 几个我实际踩过的坑

有一次我训出来的模型测试准确率始终在 60% 多,比随机猜好一点但远低于预期。查了半小时,发现是我在划分训练验证集时,用Subset拿索引,但randperm的种子和后面重新生成数据的种子不一致,导致验证集里混进了大量训练时根本没见过的样本分布……其实真正的原因是,我在比较两个实验时把验证集和测试集的变量名搞混了,评估函数拿到的是验证集 loader,而验证集只有五千样本且没打乱,恰好那批样本里有个别类别特别集中。这件事的教训是:变量命名要带上语义,val_loader和test_loader绝对不能都叫loader。

还有一次,模型训到第五轮 loss 突然变成 NaN。排查下来是权重衰减设得太大配合较大的初始学习率,参数被推得太远。把权重衰减从 1e-2 降到 1e-4,学习率从 1e-2 降到 1e-3,问题消失。这件事让我养成了一个习惯:新任务第一次跑,学习率先用 1e-3 试两个 epoch,看损失有没有在前 200 步内稳定下降,再决定要不要放大。

第三个坑是关于 BatchNorm 的。我在一个小 batch 的场景下(batch_size 设成 8)用 BatchNorm,发现训练极不稳定。原因是 batch 太小,统计量估计噪声大。这个任务用 64 的 batch 不会有这个问题,但如果你以后换到显存吃紧、只能开小 batch 的场景,要么把 BatchNorm 换成 GroupNorm,要么用梯度累积模拟大 batch。

6.3 一些能直接提升效果的实操技巧

数据增强在这个任务上要克制。随机旋转 ±10 度、平移几个像素,是有帮助的;但大幅度的裁剪、缩放会破坏数字的结构,反而掉点。我的经验是最多加两个增强:小角度旋转和轻微平移。颜色类的增强(亮度、对比度、饱和度)在这里意义不大,因为本来就是灰度图,而且训练数据的对比度分布已经很集中。真要加,标准化之后再加入噪声更合理。

学习率调度用阶梯衰减就够。每三轮把学习率砍一半,十到十五轮下来,能稳定比固定学习率高出零点二个百分点左右。用余弦退火也行,但要注意T_max设得别太长,否则前几轮学习率下降太慢,浪费算力。

如果你想把准确率再往上顶一点,最有效的不是加深网络,而是做 TTA(测试时增强)——对同一张测试图做几个小变换,把预测概率平均。这个做法在竞赛里很常见,实现也就十几行,通常能带来零点一到零点三个点的提升。不过要注意,做 TTA 的前提是增强方式不能改变语义,旋转和翻转对数字要谨慎,因为 6 和 9 翻转后就互换了,这个坑很多人踩过。

最后说一句关于复现性的。这个任务本身随机性不大,但如果你要对比两个结构的差异,固定随机种子是必须的。torch.manual_seed、numpy.random.seed、以及 DataLoader 的generator都要设。我自己做对比实验时,会把种子当成参数暴露出来,同一个结构跑三个不同种子取平均,这样比较出来的差异才可信。单次实验零点几个点的波动,很多时候纯粹是初始化的运气,不值得为它改十行代码。

返回列表