1. 手写数字识别为什么值得单独拎出来练一遍手写数字识别这个题目几乎是每个接触 PyTorch 的人绕不开的第一道坎。我见过不少人一上来就去搞目标检测、搞大模型微调结果卡在张量维度对不上、损失函数不收敛这些最基础的地方来回折腾几天就泄气了。而手写数字识别这套东西数据是现成的、任务定义是清晰的、评价指标是直白的你写错一个维度立刻报错你调整一个参数立刻能在准确率上看到反馈。这种输入-反馈链路极短的练习才是真正能把框架用法刻进肌肉记忆的方式。这篇文章要讲的东西很具体怎么用 PyTorch 把手写数字识别从数据读入一路做到模型评估和单张图片推理中间每个环节为什么这么设计、参数怎么定、坑在哪里。我假设你至少知道张量是什么、知道梯度下降在干嘛但没用 PyTorch 完整跑通过一个项目。如果你已经能熟练写训练循环那这篇里关于归一化数值由来、卷积输出尺寸与参数量手算、混淆矩阵定位问题这几块应该还是有点参考价值。先把结论摆在这同一份数据一个两层全连接网络能做到 97% 到 98% 的测试准确率一个结构很朴素的两层卷积网络能稳定在 99.2% 以上差别不在参数量而在结构对图像先验的利用方式。后面我会把这两个模型都写出来你照着跑一遍感受一下这个差距是怎么来的。2. 数据这一关比你想的更容易出错2.1 先搞清楚这份数据到底长什么样手写数字数据集的结构非常简单训练集六万张测试集一万张每张是 28×28 的灰度图像素值范围 0 到 255标签是 0 到 9 十个类别。类别分布大致均衡每个数字在训练集里大约五千到七千张没有严重的类别倾斜问题。这一点很重要因为它意味着你不需要做类别加权、不需要做过采样直接算准确率这个指标就是可信的。很多人拿到数据第一反应是print一下形状就开跑我建议你多花两分钟做这件事把前若干张图连同标签一起可视化出来或者至少把它们打印成 ASCII 字符画。你会看到有些数字写得很潦草有些笔画断断续续有些倾斜得厉害。这给你一个心理预期——模型不可能做到 100%剩下那零点几个百分点基本都是这些人类自己都可能认错的样本。另外提醒一句这类经典数据集在 PyTorch 里有一键下载的封装但它的默认下载地址有时候访问不稳定。我的习惯是先把原始文件下到本地某个固定目录然后用dataset(root..., downloadFalse)去读这样训练脚本就不会因为网络抖动挂掉。数据路径这种事最好在脚本里用一个常量统一定义别到处写相对路径不然你在不同目录下执行脚本报的错会让你怀疑人生。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, trainTrue, downloadFalse, transformtransforms.ToTensor()) loader torch.utils.data.DataLoader(raw, batch_size1000, shuffleFalse) 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(fmean{mean:.4f}, std{std:.4f})这里用的是方差等于平方的期望减期望的平方这个公式一次遍历就能算完不用把六万张图全塞内存里。算出来的结果会非常接近前面那两个数你可以自己验证一遍验证过的东西用起来才踏实。注意验证集和测试集必须用训练集算出来的均值和标准差不能各算各的。这是数据泄漏的一种隐蔽形式虽然在这个任务上影响很小但养成习惯比什么都重要。2.3 DataLoader 的三个参数决定了你训练得顺不顺batch_size、shuffle、num_workers这三个参数几乎决定了你训练过程的手感。batch_size在这个任务上我一般用 64 或 128。太小比如 8梯度噪声大训得慢太大比如 1024在只有六万样本的情况下一个 epoch 才六十步收敛会不平滑而且学习率得跟着调大。64 是一个很稳的默认值——每一步都有足够的样本把梯度噪声压下去又保留了足够的更新次数。shuffleTrue这件事没有任何商量余地。训练集必须打乱否则模型会先看到一大堆 0、再看到一大堆 1梯度方向会剧烈摆动。测试集则不要打乱保持顺序方便你后面把预测结果和原图对上。我踩过的坑是图省事把训练和测试共用一个 DataLoader 配置结果评估时想抽第 k 张图看看长什么样发现顺序全乱了。num_workers在 Linux 或 macOS 上设成 4 或 8 能明显加速数据加载但在 Windows 上经常出问题轻则卡住重则直接报进程相关的错。稳妥做法是设成 0 先用着确认整个流程跑通之后再往上加加到出问题就退回上一档。还有一个细节num_workers 0时如果自定义了数据集类里面的对象不能是打开的文件句柄或数据库连接因为会被多进程复制。这个坑在做自定义数据集时特别常见。2.4 验证集要不要从训练集里切原始数据集只给了训练和测试两部分。严格来说你应该从训练集里切一部分出来作为验证集用它来调超参、判断什么时候该停测试集只在最后用一次。不切也能跑但当你在测试集上反复试学习率、试网络结构然后报告最好的那个结果时这个数字是有乐观偏差的。我的做法是从六万里切出五千做验证剩下的五万五做训练。切的时候用固定随机种子保证每次实验的划分一致否则你比较两个模型的差异时分不清是模型差异还是数据划分差异。代码上最省事的写法是生成一个索引列表再套Subsetimport torch from torch.utils.data import Subset g torch.Generator().manual_seed(42) idx torch.randperm(60000, generatorg).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_classes10): super().__init__() self.features nn.Sequential( nn.Conv2d(1, 32, 3, padding1), # 28x28 - 28x28 nn.BatchNorm2d(32), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), # 28x28 - 14x14 nn.Conv2d(32, 64, 3, padding1), # 14x14 - 14x14 nn.BatchNorm2d(64), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), # 14x14 - 7x7 ) self.classifier nn.Sequential( nn.Flatten(), nn.Linear(64 * 7 * 7, 128), nn.ReLU(inplaceTrue), 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 加 6418496 个。第一层全连接输入是 64×7×7 也就是 3136 维到 128 维3136×128 加 128401536 个。第二层全连接 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, trainTrue, downloadFalse, transformtf) test_ds datasets.MNIST(./data, trainFalse, downloadFalse, transformtf) train_loader DataLoader(train_ds, batch_size64, shuffleTrue, num_workers0) test_loader DataLoader(test_ds, batch_size256, shuffleFalse, num_workers0) model SimpleCNN().to(device) criterion nn.CrossEntropyLoss() optimizer torch.optim.Adam(model.parameters(), lr1e-3, weight_decay1e-4) scheduler torch.optim.lr_scheduler.StepLR(optimizer, step_size3, gamma0.5) def run_epoch(loader, trainTrue): 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, trainTrue) te_loss, te_acc run_epoch(test_loader, trainFalse) scheduler.step() print(fepoch {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, digits4))跑出来你会看到一些规律性的东西这是这个任务的经典现象易混组合主要原因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_locationdevice)) model2.eval()这种方式的优点是不依赖你原来的类定义位置。缺点是加载时必须先有同样的网络结构代码键名对不上会直接报错缺一个键多一个键都会提示。第二种是存整个模型对象torch.save(model, path)用torch.load直接拿回来。看着方便实际上很坑它依赖你保存时那个类的完整路径一旦你重构了代码、改了类名或者移动了文件位置加载就会失败。跨项目复用基本没戏。第三种是存一个检查点字典把模型参数、优化器状态、当前 epoch 数、最佳准确率都塞进去。这个在需要中断续训的场景下是必须的因为优化器自己也有状态比如 Adam 的一阶二阶矩估计只存模型参数重开训练优化器状态清零会让损失曲线出现一个明显的抖动。提示torch.load时带上map_location否则在一个只有 CPU 的机器上加载 GPU 训出来的参数会报错。这个小参数能省掉很多明明存了却加载不了的困惑。5.3 单张图片从磁盘到预测结果模型训完真正的使用场景往往是给你一张图告诉我这是几。这个流程和训练时的批量处理不一样坑也多。第一个坑是通道维度。训练时输入是 N×1×28×28 的四维张量单张图读进来是 28×28ToTensor()之后是 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, dim1) conf, pred prob.max(dim1) 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都要设。我自己做对比实验时会把种子当成参数暴露出来同一个结构跑三个不同种子取平均这样比较出来的差异才可信。单次实验零点几个点的波动很多时候纯粹是初始化的运气不值得为它改十行代码。
阅读完成 · 觉得有帮助?