ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

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

从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-10

2.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×256+256 = 200,960无256×10+10 = 2,570203,530
784-512-10784×512+512 = 401,920无512×10+10 = 5,130407,050
784-256-128-10200,960256×128+128 = 32,896128×10+10 = 1,290235,146
784-512-256-10401,920512×256+256 = 131,328256×10+10 = 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 MB,Adam 优化器要存一阶和二阶动量各 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, mode='fan_in', nonlinearity='relu') 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 模式下),所以没问题;但如果忘了 eval,batch 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_classes=10, drop=0.3): super().__init__() self.body = nn.Sequential( nn.Flatten(), nn.Linear(784, 256), nn.BatchNorm1d(256), nn.ReLU(inplace=True), nn.Dropout(drop), nn.Linear(256, 128), nn.BatchNorm1d(128), nn.ReLU(inplace=True), 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, mode='fan_in', nonlinearity='relu') 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(inplace=True)省一点内存,在这个规模下无所谓,但养成习惯没坏处——注意如果某个张量后面还要用,就不能 inplace,这里 ReLU 的输入输出不会被别处引用,安全。

初始化写在__init__里、模块构建完之后统一遍历,比在每层后面手动初始化清爽。注意不要漏掉nn.init.zeros_(m.bias),虽然 PyTorch 默认也给偏置做了初始化,但显式写出来意图更清楚。

4.2 逐层形状追踪,把每一维都对上

写代码时最容易出错的地方不是语法,是形状。我把一次 batch 为 128 的前向过程完整列出来:

阶段操作输出形状说明
输入数据加载[128, 1, 28, 28]单通道灰度图
展平Flatten[128, 784]28×28=784
第一层线性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, dim=1);二是改用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(), lr=1e-3, weight_decay=1e-4 ) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=10) 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_none=True) 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(dim=1) correct += (preds == labels).sum().item() total += labels.size(0) print(f"epoch {epoch}, val acc {correct/total:.4f}")

weight_decay=1e-4是给权重加 L2 正则,和 Dropout 起到互补的正则作用。zero_grad(set_to_none=True)比默认的置零更省内存,也更干净。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(lr=0.01, momentum=0.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.4,weight 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 128,Adam lr 1e-3,weight decay 1e-4,余弦退火 T_max=10,Dropout 0.3,BN 开启。十个 epoch 跑完大约 90 秒。

epoch训练 loss训练准确率验证准确率学习率
10.32196.8%97.4%1.00e-3
20.12898.4%98.0%9.76e-4
30.09198.8%98.2%9.05e-4
50.05899.3%98.3%6.55e-4
80.03199.7%98.4%2.45e-4
100.02299.8%98.5%0.00e+0

几个观察。第一,验证准确率在第 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.4,weight decay 1e-4 提到 5e-4,各试一轮。再看数据量,如果只用了训练集的一部分做实验,那过拟合是必然的,用全量六万张再说话。

还有一个容易被忽略的原因:验证集的划分方式。如果验证集是从训练集里随机切出来的,那它和训练集同分布,差距应该很小;如果验证集是官方给的测试集,那它可能包含一些书写风格差异较大的样本,差距大一点是正常的。我一般会同时记录训练集准确率、切分验证集准确率、官方测试集准确率这三个数,三个数放一起看,才能判断到底是过拟合还是分布差异。

6.3 训练速度慢得离谱

在 CPU 上跑这套网络,一个 epoch 应该在一秒到十秒之间。如果慢到几分钟,通常是几个原因。

数据加载用了num_workers=0,所有预处理都在主进程做,GPU 在等数据。改成num_workers=4,pin_memory=True,能快不少。如果数据集很小、已经全在内存里,那就关掉 worker,反而更快。

模型没有放到正确的设备上,导致每个 batch 都在 CPU 和 GPU 之间搬数据。检查.to(device)是否对模型和每个 batch 的张量都做了。

还有一种是BatchNorm1d碰到了 batch size 为 1 的情况。这时候 BN 在训练模式下会报错(因为算不出方差),如果你做了异常处理,可能会退化成很慢的路径。检查最后的 batch 是不是只剩一张图,如果是,把drop_last=True加上。

6.4 一份可以直接对着查的速查表

现象最可能原因优先处理
准确率约 10%,loss 约 2.3标签错位、多加了 Softmax、未归一化打印样本核对、去掉输出层 Softmax
loss 变 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 再到卷积版本。这张表比任何讲解都管用,因为它把“为什么这么设计”从一句道理变成了一组可以自己复现的数字。很多时候你以为的“调参玄学”,摊在这张表上就只是几个维度的加减法。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进