从784到10:手写数字分类全连接网络架构设计

发布时间:2026/10/27 7:03:04
从784到10:手写数字分类全连接网络架构设计 做手写数字分类这个题目我前后带过好几拨刚入门的朋友几乎每个人在第一版代码跑通之后都会遇到同一个坎模型能训练损失也在降但测试集准确率要么卡在 92% 左右上不去要么干脆稳定在 10%——也就是十个数字里瞎猜的水平。等把代码翻出来逐行看问题八成不在训练循环也不在数据加载而是出在网络架构这几个字上层数拍脑袋定、宽度随手写、激活函数和初始化不匹配、输出层和损失函数各干各的。这篇就把简单网络实现手写数字分类时关于网络架构的每一个决策拆开讲从 784 维输入怎么一路走到 10 维输出每一层为什么是这个尺寸、这个顺序、这个搭配参数量的账怎么算跑起来之后曲线不对该往哪儿看。适合刚把 PyTorch 或类似框架装上、想真正搞明白全连接网络而不是抄一份代码就完事的人也适合已经能跑通但准确率不满意、想找瓶颈的人。1. 先把网络架构这件事想清楚从784个像素到10个数字1.1 为什么第一刀要切在架构上很多人调手写数字分类习惯先去调学习率、换优化器、加 epoch折腾一圈发现涨了 0.3 个点然后卡住。我的经验是在这类小任务上架构决定了天花板超参只决定你多快摸到天花板。一个宽度不匹配、缺少非线性、输出层形式错误的网络你把学习率调到 1e-5 慢慢磨最多也就磨到 95% 出头而一个结构合理的 784-256-128-10配最朴素的 Adam 和默认参数十个 epoch 内就能摸到 98% 附近。差别不在训练技巧在信息通路。原因很直接。手写数字这个任务的本质是把一张 28×28 的灰度图映射成 0 到 9 这十个类别之一。图像里携带的信息是像素之间的组合关系——某个位置有一竖、某个拐角有个弯、笔画在哪个区域闭合。全连接层做的事情是加权求和一层只能表达输入的线性组合两层才能表达组合的组合。所以层数和宽度的意义是决定这个网络有多少次机会去拼装这些局部模式。层数不够网络表达不了足够复杂的组合宽度不够每一层能并行的特征探测器数量不足很多笔画细节在加权求和时就被平均掉了。还有一层容易被忽略的架构决定了梯度的流动路径。手写数字任务的输入是 784 维输出只有 10 维中间如果收缩得太快比如直接从 784 压到 16那绝大部分输入信息在第一层就被强行压缩后面的层再努力也补不回来梯度回传时也很容易出现某一层权重更新幅度远大于其他层的情况。这不是玄学是维度变化率带来的实际影响。所以我在动手写代码之前会先在纸上把这条链路画出来输入形状是什么、每一层输出多少维、非线性放在哪、归一化插在哪、最后的输出是什么形式、和损失函数怎么对接。这张纸画完代码基本就是誊写调试的时候也知道该怀疑哪一段。1.2 一层全连接到底在算什么把一层拆开看nn.Linear(in_features, out_features)做的事情就一句话y xW^T b。输入x的形状是[batch, in_features]权重W的形状是[out_features, in_features]偏置b的形状是[out_features]输出y的形状是[batch, out_features]。拿我们的第一层举例输入是一张展平后的图784 个数输出 256 个数。那这 256 个数每个是什么是 784 个像素的一次加权求和再加一个偏置。换句话说每个输出神经元就是一个小小的“模板匹配器”——它手里握着 784 个权重相当于一张和输入同尺寸的模板图输入图和这张模板越像这个神经元的响应就越大。256 个神经元就是 256 张不同的模板各自负责捕捉一种笔画倾向。这个类比能解释很多现象。比如为什么权重初始化不能全零如果 256 个神经元的权重全一样那它们对同一张输入图算出来的响应就完全一样反向传播时拿到的梯度也一样更新之后还是完全一样。256 个神经元退化成 1 个网络白搭。这是我在教新人的时候一定会强调的一点因为它解释的不是“规范”而是“为什么必须随机”。再比如为什么第一层参数最多。784 × 256 一共二十万个权重全部集中在这一层因为输入维度本身就是 784这是任务给定的躲不掉。后面的层输入维度小了参数量自然就下来了。理解这一点后面算总参数量、估算显存、判断模型是不是“太重”心里就有谱。1.3 输入层和输出层是两个硬约束中间怎么设计有很大的自由度但两头基本是定死的。输入层这边手写数字图是 28×28 单通道展平就是 784。这一步用nn.Flatten()完成把它放在网络最前面好处是整个模型接收的还是[batch, 1, 28, 28]的四维张量数据管道不用改将来想换成卷积网络也只是把 Flatten 前面的部分替掉。我自己习惯保留这个维度上的完整性因为一旦在 Dataset 里就展平后面想加数据增强或者可视化都会别扭。输出层这边必须是 10对应 0 到 9 十个类别。但这里有个新手最容易踩的坑输出层到底要不要接 Softmax。答案是如果你用nn.CrossEntropyLoss()就绝对不要在网络最后加 Softmax。因为这个损失函数内部已经做了log_softmax再取负对数你在外面再做一次 Softmax等于对已经归一化的概率又归一化一次梯度会被严重压缩训练会变得又慢又不稳。我见过不少人 loss 一直在 2.3 附近晃——那是 ln(10) 的值意味着模型输出的十个 logits 完全相等梯度几乎传不回去往往就是这个问题叠加初始化不当造成的。正确做法是网络最后一层nn.Linear(128, 10)直接输出 logits什么都不接损失函数用CrossEntropyLoss只有在推理、算准确率、做可视化的时候才对 logits 手动做argmax或者softmax。这条约定我希望你写进自己的代码模板里能省掉大量无意义的排查时间。2. 层数与宽度的取舍为什么我最终选了784-256-128-102.1 单隐层的极限在哪里先说结论单隐层全连接网络在手写数字分类上能做到 97.7% 到 98.0% 左右再往上就很难了。我自己反复试过的配置是 784-256-10 和 784-512-10前者测试集大概 97.6%后者 97.9%差距不大。为什么单隐层会卡在这因为它的表达方式是“一次性把 784 维输入映射到一个中间空间然后直接判类”。中间空间再宽也只是一层线性变换加一次非线性激活。它能够学到“这个位置有笔画”这类一阶特征但对“两个笔画在这里交叉”“这个环在右上角闭合”这类组合特征表达起来就很吃力。手写数字里最容易混淆的几组——4 和 9、3 和 8、7 和 1——恰恰依赖的就是这类组合关系。从梯度角度看还有另一个问题。单隐层意味着输入到输出只有一次非线性变换损失对第一层权重的梯度路径很短但反过来第一层要独自承担全部的特征提取压力它的更新会非常剧烈容易出现训练前期 loss 快速下降然后突然震荡的情况。加一层之后特征提取被拆成两级前面一级学边缘和笔画后面一级学组合梯度回传的路径变长但每级的负担变轻训练曲线会平滑很多。我自己的判断标准很简单如果单隐层训练到 5 个 epoch 之后训练集准确率还能到 99% 但验证集停在 97.5% 不动说明模型容量已经吃满了再加宽意义不大该加层了。2.2 两层是不是越多越好不是。我试过 784-256-256-128-10 这种四层结构测试集准确率反而比三层低了 0.1 到 0.2 个点训练时间还长了三成。原因有两个。一是这个任务的复杂度本身有限。784 维输入、10 类输出中间用两层隐层已经把决策边界表达得足够细了再深就是在拟合噪声。MNIST 训练集里本身就存在标注歧义和书写极端的样本模型容量过大会把这些也记住。二是深了之后梯度传播变得更难。虽然 ReLU 缓解了梯度消失但在没有残差连接的情况下四层全连接网络的训练稳定性明显不如两三层。我遇到过一次 loss 前三个 epoch 几乎不动第四个 epoch 突然掉下去的情况就是深层网络典型的“预热期”对新手来说很容易误判成代码写错了。所以我的建议是这个任务上两层隐层是性价比最高的选择。再多收益递减得厉害再少天花板明显。2.3 宽度的账要一笔一笔算确定了两层接下来是宽度。这里有三个备选方案我把参数量都算出来了。单层全连接的参数量公式是in × out out三层加起来就是总参数。结构第一层参数第二层参数输出层参数总参数784-256-10784×256256 200,960无256×1010 2,570203,530784-512-10784×512512 401,920无512×1010 5,130407,050784-256-128-10200,960256×128128 32,896128×1010 1,290235,146784-512-256-10401,920512×256256 131,328256×1010 2,570535,818看这张表能读出几件事。第一784-256-128-10 的总参数量只比 784-256-10 多了 15%但多了一层非线性组合的机会这是非常划算的买卖。第二784-512-10 的参数比三层方案多了 73%却只换来一级非线性明显不划算。第三784-512-256-10 参数翻了一倍多在这个任务上属于过度设计。顺带算一下内存。235,146 个参数每个 float32 占 4 字节权重本身大约 0.9 MB。加上梯度 0.9 MBAdam 优化器要存一阶和二阶动量各 0.9 MB总共约 3.6 MB。激活值这边batch size 取 128 时第一层输出是 128×256 个数约 128 KB第二层 128×128 约 64 KB加起来不到 0.5 MB。整个模型连 CPU 都能轻松跑这也是我一直建议新手从全连接网络入手的原因——它把所有算力问题都消掉了你可以专心理解架构本身。第一层宽 256 还是 512我用 256。784 到 256 是把维度压缩到约三分之一这个比例比较温和信息损失可控到 512 只压缩到三分之二不到特征保留得多但参数翻倍而实际准确率提升不到 0.5 个点。第二层从 256 收到 128是继续做特征压缩把分散的特征整合成更抽象的表示为最后的 10 类决策做准备。整个形状像漏斗先宽后窄这个模式在分类任务里相当通用。3. 每一层的细节设计激活、初始化、归一化与正则3.1 激活函数选ReLU但要知道它的脾气隐藏层用 ReLU理由是它在正区间的梯度恒为 1不会像 Sigmoid 那样在饱和区把梯度压到接近零。784 维输入意味着第一层的加权和数值范围可能比较大如果用 Sigmoid很容易一上来就进入饱和区训练会停滞很久。但 ReLU 有个副作用叫“神经元死亡”。当某个神经元的加权和落在负区间ReLU 输出 0梯度也是 0这个神经元就再也不会更新了。如果学习率设得太大一次更新就可能把大批神经元推到负区间网络实际容量瞬间缩水。我在一次实验里把学习率误设成 0.1 跑 SGD第一个 epoch 之后统计发现第一层 256 个神经元里有将近 60 个永久输出 0测试准确率直接卡在 96% 上不去。解决办法有三个我一般同时用前两个。一是学习率保守一点Adam 从 1e-3 起SGD 从 0.01 起别一上来就冲。二是初始化用合适的方差让初始状态下加权和大致落在零附近对称分布这点下一节展开。三是如果确实想更稳可以换成 LeakyReLU 或者 GELU代价是多一点点计算量在这个规模的网络上完全可以忽略。我自己做最终版本时会用 ReLU因为它最简单、最快出问题也好定位做对比实验时会换上 LeakyReLU看看死亡神经元的影响到底有多大。3.2 权重初始化Kaiming的具体算法全零初始化不行前面说过原因。全用同一个小的常数也不行同样的问题。太小的随机初始化比如标准差 0.01会导致第一层输出几乎全是接近零的小数经过几层之后信号衰减得厉害梯度也跟着衰减。太大的初始化比如标准差 1第一层加权和数值可能到几十上百ReLU 之后大片激活但反向传播时梯度也会被放大容易震荡。Kaiming 初始化的思路是让每一层输出的方差和输入的方差保持一致。对于 ReLU 激活公式是标准差等于sqrt(2 / fan_in)其中fan_in是该层的输入维度。以第一层为例fan_in 784标准差是sqrt(2/784) sqrt(0.002551) ≈ 0.0505。也就是说784 个权重都从均值 0、标准差 0.0505 的正态分布里抽。这样输入 784 个数、各自乘上这个量级的权重再求和输出的方差大致和输入方差同量级不会爆炸也不会消失。第二层fan_in 256标准差是sqrt(2/256) 0.0884。可以看到输入维度越小需要的初始权重反而越大这正是为了补偿求和项数变少带来的方差下降。代码上不用自己算PyTorch 提供了现成的for m in self.modules(): if isinstance(m, nn.Linear): nn.init.kaiming_normal_(m.weight, modefan_in, nonlinearityrelu) nn.init.zeros_(m.bias)偏置初始化为零是安全的因为对称性已经被随机的权重打破了偏置全零不会导致神经元退化成一样。这里有一个细节值得说如果某一层后面接了 BatchNorm那么这一层权重的初始尺度其实不那么关键了因为 BN 会把输出强行拉回标准分布。这时候可以用更简单的初始化甚至用 PyTorch 的默认初始化都能跑。但我仍然建议保持 Kaiming因为它让网络在“还没有 BN 统计量”的最初几个 batch 里也不至于太离谱。3.3 BatchNorm放在哪值不值得加BatchNorm 在全连接网络里的位置是Linear → BatchNorm → ReLU注意是在激活之前。它做的事情是把这一层的输出按 batch 维度做标准化减均值除标准差然后再用两个可学习参数做缩放和平移。效果是让每一层的输入分布保持稳定后面的层不用反复适应前面层分布的变化。在这个任务上BN 带来的收益非常直观。我做过对照不加 BN 的版本用默认初始化加 Adam 1e-3大概要 4 到 5 个 epoch 测试准确率才能爬到 97.5%加了 BN两个 epoch 就到 97.8%十个 epoch 收在 98.3% 附近。而且加 BN 之后对初始化和学习率的宽容度大得多我把学习率从 1e-3 换到 3e-3 甚至 5e-3不加 BN 的版本早就发散了加了 BN 的还能正常收敛。但 BN 不是没有代价。第一它依赖 batch 统计量batch size 太小比如小于 8时统计量噪声很大反而有害。我用 batch 128 和 batch 64 都试过都正常试过 batch 4训练曲线明显毛糙。第二训练和推理的行为不一致训练用当前 batch 的统计量推理用滑动平均。这意味着你在验证之前必须调用model.eval()否则验证结果会莫名其妙地差一截——这个坑我踩过不止一次验证集准确率比训练集低 2 个点排查半天发现是忘了切 eval 模式。第三BN 和 Dropout 一起用的时候两者的正则效果会互相干扰有些人建议把 Dropout 放在 BN 之前我在这个任务上实测差别不大顺序用Linear → BN → ReLU → Dropout就行。还有一个小提醒如果将来你要做单张图片的推理比如读一张手写数字照片丢进去预测记得 batch size 是 1这时候 BN 用的是训练时积累的滑动统计量eval 模式下所以没问题但如果忘了 evalbatch size 1 的统计量就是那个样本自己减完均值除完标准差之后输出恒为零预测结果会完全随机。这类问题在排查“模型训练好好的一上线就全错”时是头号嫌疑。3.4 Dropout的比例和位置Dropout 的作用是在训练时随机把一部分神经元的输出置零强迫网络不依赖某几个特定神经元从而抑制过拟合。在手写数字这个任务上全连接层的参数有二十多万训练样本六万张过拟合是真实存在的——不加任何正则训练集准确率能到 99.9%测试集只有 98.0%。我的选择是在两个隐层后面各加一个Dropout(0.3)。比例怎么定经验上隐层神经元越多、任务越简单比例可以高一些0.2 到 0.5 是常用区间。我试过 0.2、0.3、0.5 三档测试准确率分别是 98.2%、98.4%、98.1%。0.5 的时候训练前期收敛明显变慢因为每次只有一半神经元在工作有效容量下降太多。0.2 的时候正则力度不太够训练集和测试集的准确率差距还有 1.2 个点。0.3 是个比较舒服的中间值。位置方面Dropout 只加在隐层绝对不要加在输出层。输出层是最终判类的地方把它的 10 个 logits 随机置零会让损失函数拿到的信号直接错乱。这个错误不常见但我确实见过有人在nn.Sequential末尾顺手加了一个 Dropout然后困惑为什么 loss 一直降不下去。还有一个必须记住的点Dropout 在推理时不生效。这同样由model.eval()控制。训练时它是随机丢弃推理时它是把输出按保留概率缩放PyTorch 的实现是训练时除以保留概率推理时不做处理也就是所谓的 inverted dropout。如果你的验证循环忘了切 eval验证准确率会莫名其妙地低好几个点而且每次跑出来的数还不一样这种随机性很容易把人带偏。4. 从零把这套架构落到代码里4.1 网络定义的完整写法把前面所有决策拼起来网络长这样import torch import torch.nn as nn class DigitNet(nn.Module): def __init__(self, num_classes10, drop0.3): super().__init__() self.body nn.Sequential( nn.Flatten(), nn.Linear(784, 256), nn.BatchNorm1d(256), nn.ReLU(inplaceTrue), nn.Dropout(drop), nn.Linear(256, 128), nn.BatchNorm1d(128), nn.ReLU(inplaceTrue), nn.Dropout(drop), nn.Linear(128, num_classes), ) self._init_weights() def _init_weights(self): for m in self.modules(): if isinstance(m, nn.Linear): nn.init.kaiming_normal_(m.weight, modefan_in, nonlinearityrelu) nn.init.zeros_(m.bias) def forward(self, x): return self.body(x)几点说明。nn.Flatten()默认从第 1 维开始展平输入[B, 1, 28, 28]会变成[B, 784]正好符合预期不用手动写view。BatchNorm1d接收的是二维张量[B, features]所以放在 Linear 之后没问题。ReLU(inplaceTrue)省一点内存在这个规模下无所谓但养成习惯没坏处——注意如果某个张量后面还要用就不能 inplace这里 ReLU 的输入输出不会被别处引用安全。初始化写在__init__里、模块构建完之后统一遍历比在每层后面手动初始化清爽。注意不要漏掉nn.init.zeros_(m.bias)虽然 PyTorch 默认也给偏置做了初始化但显式写出来意图更清楚。4.2 逐层形状追踪把每一维都对上写代码时最容易出错的地方不是语法是形状。我把一次 batch 为 128 的前向过程完整列出来阶段操作输出形状说明输入数据加载[128, 1, 28, 28]单通道灰度图展平Flatten[128, 784]28×28784第一层线性Linear(784,256)[128, 256]参数 200,960批归一化BatchNorm1d(256)[128, 256]形状不变激活ReLU[128, 256]形状不变丢弃Dropout(0.3)[128, 256]训练时部分置零第二层线性Linear(256,128)[128, 128]参数 32,896批归一化BatchNorm1d(128)[128, 128]形状不变激活ReLU[128, 128]形状不变丢弃Dropout(0.3)[128, 128]训练时部分置零输出层Linear(128,10)[128, 10]参数 1,290输出 logits损失CrossEntropyLoss标量内部含 log_softmax这张表我建议每个人都自己手写一遍。写的过程中你会发现BatchNorm1d只改变数值不改变形状Dropout也是所以真正决定维度的是三个Linear和最初那个Flatten。把这三个数字对上形状就不会错。反过来如果你遇到类似mat1 and mat2 shapes cannot be multiplied的报错第一件事就是回来对这张表看看哪一层的in_features和上一层的输出对不上——最常见的是把 784 写成了 28 或者写成了 28×28×1 之外的数。4.3 损失函数和输出层必须配套前面提过网络输出 logits损失用CrossEntropyLoss。这里再补一个实操细节CrossEntropyLoss期望的输入是[B, C]的浮点张量标签是[B]的长整型张量取值范围是0到C-1。也就是说标签不能是 one-hot必须是类别索引。如果你的数据集返回的是 one-hot 编码有两种处理方式一是把它转成索引用torch.argmax(label, dim1)二是改用BCELoss配合输出层的 Sigmoid但那样就变成十个独立的二分类问题训练效率和准确率都不如交叉熵。我推荐第一种转索引最省事。另外提醒一个数值稳定性问题。如果你想自己算损失比如加标签平滑或者做知识蒸馏记得用nn.CrossEntropyLoss或者F.cross_entropy而不是自己写-log(softmax(x))。因为softmax之后再取log当某个 logit 特别大时会出现log(1.0)得到-inf的情况梯度直接变 NaN。框架里的实现用的是 log-sum-exp 技巧数值上稳定得多。4.4 训练循环里和架构相关的几行完整训练循环不长但有几行和架构强相关值得单独说。model DigitNet().to(device) criterion nn.CrossEntropyLoss() optimizer torch.optim.Adam( model.parameters(), lr1e-3, weight_decay1e-4 ) scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max10) for epoch in range(10): model.train() for images, labels in train_loader: images, labels images.to(device), labels.to(device) optimizer.zero_grad(set_to_noneTrue) logits model(images) loss criterion(logits, labels) loss.backward() optimizer.step() scheduler.step() model.eval() correct, total 0, 0 with torch.no_grad(): for images, labels in val_loader: images, labels images.to(device), labels.to(device) preds model(images).argmax(dim1) correct (preds labels).sum().item() total labels.size(0) print(fepoch {epoch}, val acc {correct/total:.4f})weight_decay1e-4是给权重加 L2 正则和 Dropout 起到互补的正则作用。zero_grad(set_to_noneTrue)比默认的置零更省内存也更干净。CosineAnnealingLR让学习率按余弦曲线从 1e-3 衰减到接近零最后几个 epoch 收敛得更稳。这两处不是什么高深技巧但配上前面那套架构效果很实在。最容易漏的是model.train()和model.eval()这两个切换。它们控制着 BatchNorm 用哪套统计量、Dropout 是否生效。我建议把它们写成训练函数和评估函数里的第一行别依赖默认状态因为默认状态会随着你调用的顺序变来变去是 bug 的温床。5. 训练过程实录参数选择与曲线解读5.1 学习率和batch size的组合逻辑这两个参数经常被一起讨论因为它们通过“一个 epoch 内参数更新多少次”这个量耦合在一起。batch size 128、训练集六万张一个 epoch 更新 469 次。batch size 64更新 938 次。同样的 epoch 数小 batch 的参数更新次数翻倍理论上收敛更快但每次更新的梯度噪声更大曲线更抖。我在这个任务上试过 32、64、128、256 四档最终测试准确率分别是 98.2%、98.4%、98.4%、98.3%差别很小但训练时间的差别明显batch 32 因为更新次数多跑完十个 epoch 的时间是 batch 128 的两倍多。综合下来我用 128。学习率方面Adam 的默认 1e-3 在这个任务上是个很好的起点。我试过 3e-4收敛慢十个 epoch 只到 97.6%试过 3e-3前两个 epoch 掉得很快但第四个 epoch 开始震荡最终 98.1%不如 1e-3。所以在这套架构加 BN 的前提下1e-3 配余弦退火是最省心的组合。如果用 SGD起点要换。SGD 对学习率敏感得多我用SGD(lr0.01, momentum0.9)跑十个 epoch 能到 98.0%但前三个 epoch 明显比 Adam 慢。有一个经验规则batch size 扩大 k 倍SGD 学习率也放大 k 倍左右这是线性缩放。比如 batch 从 64 加到 256学习率可以从 0.01 提到 0.04。但这个规则在 Adam 上不适用Adam 的自适应特性和 batch size 的关系没那么线性别硬套。5.2 曲线怎么读问题往哪找训练过程中我会同时看三条曲线训练 loss、训练准确率、验证准确率。这三条线的相对关系基本能定位所有常见问题。训练 loss 平稳下降、训练准确率和验证准确率一起上升、两者差距在 1 个点以内——健康继续跑。训练准确率到 99.9%验证准确率停在 98.0% 附近不再涨两条线差距超过 1.5 个点——过拟合。这时候先别急着加网络容量反而要加正则Dropout 从 0.3 提到 0.4weight decay 从 1e-4 提到 5e-4或者加一点数据增强随机平移几个像素、小角度旋转。我试过加随机平移 2 个像素验证准确率能提 0.2 个点但训练时间增加性价比一般看你追求什么。训练 loss 降得很慢训练准确率上不去验证准确率也跟着低——欠拟合或者学习率太小。先看学习率再考虑加宽加层。这个任务上欠拟合很少见因为模型容量足够。训练 loss 突然变成 NaN 或者剧烈震荡——学习率太大或者输入没有归一化。784 个像素值如果还是 0 到 255 的原始范围第一层加权和会大到离谱ReLU 之后全是激活反向传播直接爆掉。所以数据预处理一定要做ToTensor()把像素压到 0 到 1再Normalize((0.1307,), (0.3081,))减均值除标准差。这两个数字是整个训练集的像素均值和标准差用一次算好写死就行不用每个 batch 重算。训练 loss 一直在 2.3 附近不动——这是 ln(10)说明输出层的十个 logits 几乎相等。三个可能一是输出层外面多加了 Softmax 又用了 CrossEntropyLoss二是学习率太小或者初始化太离谱三是标签全错位了比如标签是 1 到 10 而不是 0 到 9导致索引越界或者错位匹配。5.3 一次完整的实测记录为了给你一个可对照的基准我把最近一次跑的数据记下来。环境是单核 CPU 就够实际用了四核。数据集六万张训练、一万张测试。batch size 128Adam lr 1e-3weight decay 1e-4余弦退火 T_max10Dropout 0.3BN 开启。十个 epoch 跑完大约 90 秒。epoch训练 loss训练准确率验证准确率学习率10.32196.8%97.4%1.00e-320.12898.4%98.0%9.76e-430.09198.8%98.2%9.05e-450.05899.3%98.3%6.55e-480.03199.7%98.4%2.45e-4100.02299.8%98.5%0.00e0几个观察。第一验证准确率在第 3 个 epoch 就到了 98.2%后面 7 个 epoch 只涨了 0.3 个点边际收益很明显——这说明在这个架构下十个 epoch 已经过了收益拐点再多跑意义不大。第二训练准确率和验证准确率的差距从 epoch 1 的 0.6 个点扩大到 epoch 10 的 1.3 个点典型的过拟合趋势但幅度可以接受正则起了作用。第三学习率退火让最后两个 epoch 的 loss 掉得特别干净如果不加 scheduler最后 loss 会停在 0.03 附近验证准确率大概低 0.1 个点。如果想再往上推一点最有效的两招是数据增强和模型集成。增强前面说过收益约 0.2 个点集成五六个不同随机种子的模型做投票能到 98.8% 左右但推理成本翻五倍实际项目里要权衡。再往上就得换卷积网络了那是另一个话题。6. 常见问题与排查技巧实录6.1 准确率卡在10%附近这是最典型也最好排查的一类。模型输出十个类别瞎猜的期望准确率就是 10%所以看到这个数字基本可以断定模型什么都没学到。按概率从高到低查这几项。第一标签和数据是否对齐。有一次我接手一份代码数据加载器里做了 shuffle但标签数组忘了跟着 shuffle结果图和标签完全错位训练 loss 一直在 2.3 附近准确率 9.8%。这种问题只能靠肉眼抽查把前几张图和对应标签打印出来看一眼两分钟就能确认。第二输出层和损失函数是否配套。前面说过多加了 Softmax 会导致梯度被压缩。第三输入是否做了归一化。如果用原始 0 到 255 的像素配合不当的初始化第一层输出会极大ReLU 之后全激活梯度要么爆要么消失。第四学习率是不是大得离谱导致每次更新都把权重推到极端值。我自己的排查顺序是先打印一个 batch 的图和标签看对不对再看 loss 的第一个值是不是 ln(10) 附近然后检查输出层和损失函数最后才怀疑学习率和初始化。这个顺序是按“排查成本从低到高”排的前面几项都是一两行代码就能验证的事。6.2 训练集很准验证集上不去训练集 99.8%、验证集 97.5%差距 2.3 个点这是标准的过拟合。注意一个反直觉的点这个任务上过拟合不一定是坏事因为验证集本身只有一万张0.1 个点的波动就是十张图的差别统计噪声不小。处理时先确认几件事。BN 和 Dropout 在验证时是否关了model.eval()这一项如果漏了验证准确率会低得莫名其妙而且每次跑的数还不一样。然后看正则强度Dropout 0.3 提到 0.4weight decay 1e-4 提到 5e-4各试一轮。再看数据量如果只用了训练集的一部分做实验那过拟合是必然的用全量六万张再说话。还有一个容易被忽略的原因验证集的划分方式。如果验证集是从训练集里随机切出来的那它和训练集同分布差距应该很小如果验证集是官方给的测试集那它可能包含一些书写风格差异较大的样本差距大一点是正常的。我一般会同时记录训练集准确率、切分验证集准确率、官方测试集准确率这三个数三个数放一起看才能判断到底是过拟合还是分布差异。6.3 训练速度慢得离谱在 CPU 上跑这套网络一个 epoch 应该在一秒到十秒之间。如果慢到几分钟通常是几个原因。数据加载用了num_workers0所有预处理都在主进程做GPU 在等数据。改成num_workers4pin_memoryTrue能快不少。如果数据集很小、已经全在内存里那就关掉 worker反而更快。模型没有放到正确的设备上导致每个 batch 都在 CPU 和 GPU 之间搬数据。检查.to(device)是否对模型和每个 batch 的张量都做了。还有一种是BatchNorm1d碰到了 batch size 为 1 的情况。这时候 BN 在训练模式下会报错因为算不出方差如果你做了异常处理可能会退化成很慢的路径。检查最后的 batch 是不是只剩一张图如果是把drop_lastTrue加上。6.4 一份可以直接对着查的速查表现象最可能原因优先处理准确率约 10%loss 约 2.3标签错位、多加了 Softmax、未归一化打印样本核对、去掉输出层 Softmaxloss 变 NaN学习率过大、输入未归一化学习率降到 1e-3 以下、加 Normalize训练准验证低差距大于 2 点过拟合提高 Dropout、加 weight decay、数据增强验证准确率每次跑都不一样且偏低忘了 model.eval()评估函数第一行加 eval前几个 epoch loss 几乎不动初始化方差过小、学习率过小换 Kaiming 初始化、学习率乘 3训练前期 loss 剧烈震荡学习率过大、batch 过小学习率减半、batch 加到 128部分神经元永久输出 0ReLU 死亡降学习率、换 LeakyReLU形状不匹配报错Linear 的 in_features 写错对照逐层形状表提示这张表里前两行占了新手问题的七成以上遇到问题先往这两行上靠别一上来就怀疑模型结构。7. 架构还能往哪儿长7.1 换成卷积网络的临界点全连接网络在这份数据上的天花板大概就是 98.5% 左右想突破就得换思路。手写数字是图像图像有强烈的局部相关性——相邻像素属于同一笔画远处像素的关系弱得多。全连接层把这种空间结构完全丢掉了784 个权重对所有位置一视同仁。卷积层则显式地利用了局部性和平移不变性参数量还少得多。一个最小的卷积网络大概长这样两个 3×3 卷积层通道数 32 和 64每个卷积后面接 ReLU 和 2×2 最大池化最后展平接一个全连接到 10。参数量不到十万比全连接还少但测试准确率能到 99.2% 以上。这个提升不是靠调参调出来的是靠架构里内置的先验知识换来的。不过我不建议一上手就跳过去。全连接网络把所有东西都摊在明面上你能清楚地看到每个参数在干什么卷积网络多了通道、卷积核、池化这些概念出问题时排查路径会长很多。先把 784-256-128-10 这套吃透理解清楚每一层的形状变化、参数来源、梯度路径再去碰卷积会顺很多。7.2 加残差和加宽的选择如果坚持在全连接网络上做优化还有两条路。一是加残差连接。把第二层的输入改成第一层输出加第二层输出也就是x x f(x)前提是维度要对齐256 和 128 不一致的时候需要加一个线性投影。残差能让梯度更顺畅地回传理论上支持更深的网络。但在这个只有两三层隐层的规模上收益很小我试过加到五层配残差测试准确率 98.4%还不如三层不加残差的 98.5%。原因是任务太简单深度的收益被过拟合抵消了。二是继续加宽。784-1024-512-10参数量到 130 万测试准确率 98.4%和 256-128 的方案打平但训练时间翻了三倍。这就是典型的“参数堆上去但信息量没跟上”多出来的容量在拟合噪声。所以我个人的结论是这套 784-256-128-10 配 BN 和 Dropout已经接近全连接方案在这个任务上的性价比最优点。再往上要么换架构要么换数据增强要么做集成单纯在网络结构上做加法的收益已经很小了。我在实际项目里带人做这个练习时最后都会让他们做一件事把每一版的参数量、训练时间、测试准确率记在一张表里从最简单的 784-64-10 一路记到 784-256-128-10 再到卷积版本。这张表比任何讲解都管用因为它把“为什么这么设计”从一句道理变成了一组可以自己复现的数字。很多时候你以为的“调参玄学”摊在这张表上就只是几个维度的加减法。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询