从零手写全连接网络:手写数字分类与反向传播实战

发布时间:2026/10/27 13:31:30
从零手写全连接网络:手写数字分类与反向传播实战

1. 为什么我坚持从零手写一个简单网络做数字分类

前阵子帮一个刚转方向的朋友看代码,他丢过来一段用现成框架写的训练脚本,十几行,跑出来准确率不低,但问他梯度是怎么从输出层回传到第一层的,他卡住了。这事儿挺典型,也很正常——框架把求导、参数更新、显存调度全包了,你只管写模型结构。可一旦训练不收敛,你就只能靠猜。所以当有人问“想真正搞懂神经网络训练到底在干什么,该从哪儿下手”时,我一般的回答都是:找个简单网络,自己把梯度下降算法写一遍,用最朴素的手写数字分类任务当靶子。这个练法的价值不在于最后那个模型多强,而在于你会被迫把每一个矩阵形状、每一次减法、每一个学习率的影响都算清楚,从此看训练日志不再凭感觉。

我这次要拆的就是这么一件事:拿一个只有一层隐藏层的全连接网络,配上手写数字分类这个经典任务,从零实现前向传播、损失计算、反向传播和梯度下降更新,把整个训练循环摊开在桌面上看。适合的人群很明确:学过一点线性代数和微积分、知道矩阵乘法怎么算、但对“反向传播”只有模糊印象的人。如果你已经能熟练调参调模型结构,这篇文章对你价值有限;但如果你正卡在“看得懂公式、写不出代码”或者“代码跑起来了、不知道为啥能work”的阶段,那这套流程基本可以照着抄。另外我得先说清楚边界:这套实现是为了理解原理,不是为了追求指标,它跑手写数字能到 97% 左右的测试准确率,但换成稍微复杂一点的任务就会力不从心,原因我在第 1.3 节会讲透。

1.1 框架封装掉的到底是什么

很多人第一次接触网络训练,看到的是model.fit()这样一个调用,输入数据和标签,输出训练日志。中间发生了什么,完全是个黑盒。我不反对用框架,生产环境当然要用,但如果你想建立直觉,最好至少有一次亲手把loss.backward()里的那件事做出来。因为反向传播的本质并不神秘,它就是链式法则在计算图上的系统化应用:每一层把自己的输出误差乘以本层的局部导数,再把结果往上一层传。手写一遍之后你会发现,所谓“自动求导”,不过是把这件事做得更通用、更不容易出错而已。

具体到梯度下降,框架里通常一行优化器就搞定了,但这一行里藏着好几个决策:更新用的是全量样本还是小批量、学习率是常数还是衰减、要不要加动量项。这些决策对训练结果的影响很大,而框架默认值往往只是“能用”,不是“最优”。手写的时候,你会被迫给每个超参数一个明确的值,也就自然明白了它们各自在管什么。我自己的习惯是,每学一个新算法,先用纯数组实现一遍最小版本,跑通了再换框架重写,两次实现的结果对不上,往往就是我对算法的理解有偏差。

还有一点容易被忽略:手写实现能让你对数值问题变得敏感。比如 softmax 里如果不做数值稳定处理,指数运算很容易溢出成inf,然后整个损失变成nan,训练直接崩掉。框架里这些处理都封装好了,你永远踩不到这个坑,也就永远不知道有这么个坑。等你哪天自己写个自定义层,第一个撞上的往往就是它。

1.2 手写数字分类为什么成了最好的练手靶子

选任务这件事上,我是有点执念的:入门阶段,任务本身的复杂度要尽可能低,这样出问题时你能确定是算法实现错了,而不是任务太难。手写数字分类正好满足这一点。每张图是 28×28 的灰度像素,展开成 784 维向量,标签只有 0 到 9 十个类别,输入输出都极其干净。数据量适中,几万条训练样本,用纯数组实现跑一遍也就几分钟到十几分钟,改一次代码能很快看到反馈,这个迭代速度对调试太重要了。

更关键的是,这个任务的可分性足够好。数字 0 和 1 在像素分布上差别巨大,哪怕是个很笨的模型也能分对一大半;难一点的 4 和 9、3 和 8 才需要模型学到一点结构。这意味着你的模型只要能正常工作,准确率就会明显高于随机猜测的 10%,你能立刻判断“我的梯度下降到底有没有在干活”。如果换个任务,比如细粒度图像分类,模型效果不好可能是算法问题,也可能是任务本来就难,排查成本高得多。

我做实验时习惯先看两个基准:一个是纯随机的 10%,一个是“全猜同一个类”的基线,大概也在 10% 上下。只要训练几轮之后准确率能爬到 80% 以上,基本可以确认前向和反向的链路是通的,剩下的就是调参和看细节。这个判断标准很粗糙,但极其有效,比盯着损失曲线猜要靠谱得多。

1.3 这套简单网络的边界在哪里

有必要把话说在前面,免得你对结果有不切实际的期待。一个单隐藏层的全连接网络,本质上是把 784 维输入做一次线性变换,过个非线性激活,再做一次线性变换到 10 维。它能学到的东西,是“某些像素位置上的亮度组合起来能区分出某个数字”,但它学不到“数字的形状”或者“笔画的拓扑关系”。所以它对平移、旋转、缩放都很敏感——你把一张 5 往左挪几个像素,它可能就认不出来了。这不是训练没做好,是模型结构决定的表达力上限。

这也是为什么后面大家会转向卷积网络。卷积的核心优势是权值共享和局部感受野,它能捕捉空间上的局部模式,并且对平移有一定的容忍度。但在你还没完全搞清楚梯度下降之前,直接上卷积网络只会让你多一层迷雾。我的建议是:先用全连接网络把手写数字做到 97% 以上,然后再去理解卷积,对比两者在同一个任务上的表现差异,那个差异会非常直观地告诉你“为什么结构设计比调参重要”。

另外一个边界是训练成本。这套实现里,所有的矩阵运算都在 CPU 上用数组库跑,没有并行加速。784→128→10 这个规模下,一个 epoch 大概几秒钟到几十秒,完全可以接受。但如果你把隐藏层扩到 1024,或者堆五六层,速度就会明显拖慢,调试体验变差。所以入门阶段,模型规模要克制,够用就行,把注意力留给算法本身。

2. 前向传播与损失:一张图是怎么变成十个概率的

这部分是整个流程的入口,也是最容易写错的地方。我见过不少实现,代码能跑,但形状对不上或者概率之和不为 1,最后训练出来的模型看着有准确率,实际上损失函数算的是错的。所以这一节我会把每一步的形状变化写清楚,你可以对照着检查自己的实现。

从数据说起。读进来的每张图是 28×28 的二维数组,取值 0 到 255。我们第一步要把它拉平成 784 维的列向量,然后把值域压到 0 到 1 之间,也就是除以 255。为什么要归一化?因为如果输入值在 0 到 255 这个量级,第一层的加权和就会非常大,经过指数运算后极易溢出,同时也让梯度尺度失衡。归一化之后,输入都在 1 以内,权重初始化的尺度才好控制。这一步看着琐碎,但实际影响很大,我最初偷懒没做归一化,损失直接在高位震荡不降,排查了半天才发现是这个原因。

2.1 从像素到向量的预处理细节

预处理这块我想多说两句,因为它经常被当成“顺手带过”的步骤,实际上坑不少。除了除以 255,还有一个选择是再做标准化,也就是减去均值、除以标准差。对于手写数字这类数据,除以 255 已经够用了,标准化带来的提升不明显,反而多一个要记的参数。所以我倾向于保持简单:只用 0 到 1 的缩放。但如果你发现训练初期损失下降很慢,可以试试标准化,有时候能改善收敛速度。

标签处理是另一件事。原始的标签是 0 到 9 的整数,而交叉熵损失需要的是一个概率分布,也就是长度为 10 的独热向量,正确类别位置是 1,其余是 0。这里有个细节:整数标签和独热编码在计算损失时是等价的,用整数标签可以直接索引到对应的预测概率,省掉构造独热向量的开销,数值上也更稳。我早期实现用的是独热版本,后来改成索引版本,代码短了一截,速度也快一点。两种写法都对,看你习惯。

还有批量组织的问题。训练时我们不会一张一张地喂数据,而是一个批次一个批次地送。假设批次大小是 64,那么输入矩阵的形状就是 64×784,每一行是一个样本。这个形状约定很重要,因为它决定了后面所有权重矩阵的乘序。我踩过一次坑:写代码时一会儿用行向量一会儿用列向量,结果前向能过、反向报错,调了很久。定下“样本在行、特征在列”这个规矩之后,就再没乱过。

2.2 两层网络的形状与参数量计算

网络结构我定得很简单:输入 784 维,隐藏层 128 个神经元,激活函数用 ReLU,输出层 10 个神经元,对应十个类别。第一层的权重矩阵形状是 784×128,偏置是长度 128 的向量;第二层权重是 128×10,偏置长度 10。前向计算就是两次矩阵乘法加一次激活:先算z1 = x @ W1 + b1,再过 ReLU 得到a1,然后z2 = a1 @ W2 + b2,最后对z2做 softmax 得到概率。

参数量算一下:第一层 784×128 = 100352 个权重,加 128 个偏置,共 100480;第二层 128×10 = 1280 个权重,加 10 个偏置,共 1290。总计 101770 个可训练参数。这个数字不算大,用数组存也就是几百 KB,但它是我们后面理解梯度形状的基础——每一个参数都有一个对应的梯度,梯度矩阵的形状必须和参数矩阵完全一致,这是反向传播实现正确性的第一道检查。

隐藏层为什么选 128?说实话没有严格推导,更多是经验。太小比如 8 或 16,模型容量不够,准确率上不去;太大比如 512,训练变慢,而且在小数据集上容易过拟合。128 在这个任务上是个比较甜的中间值,我实测 64、128、256 三档,128 和 256 的最终准确率差不太多,但 128 训练快近一倍。入门阶段我建议就用 128,先把流程跑通,等你对训练过程有感觉了,再去做这种对比实验。

2.3 softmax 与交叉熵的数值稳定处理

输出层拿到的是 10 个实数,可正可负,没有概率含义。softmax 的作用是把它们变成概率:先对每个数取指数,再除以所有指数之和。数学上很干净,实现上有个大坑——如果某个数比较大,比如 800,指数运算的结果直接溢出。解决办法是老生常谈的减去最大值:先找出这 10 个数里的最大值,全部减去它,再做指数。因为减去同一个常数不改变 softmax 的结果,但能保证最大的那个指数项是exp(0)=1,其余的都不超过 1,绝不会溢出。

交叉熵损失衡量的是预测分布和真实分布之间的差距。当真实标签是第 k 类时,损失就是-log(p_k),也就是正确类别的预测概率越接近 1,损失越接近 0。这个形式有个很好的性质:它对输出层梯度的表达式极其简洁,就是“预测概率减去真实标签”,这一点在第 3 节会详细推。实现时要注意,如果p_k恰好是 0,取对数会得到负无穷,所以要给它加一个极小值做保护,比如 1e-12。这是工程上的必要妥协。

我用一个具体的例子过一遍。假设某张图是数字 3,模型输出的十个概率是[0.01, 0.02, 0.05, 0.8, 0.02, 0.03, 0.02, 0.03, 0.01, 0.01],正确类别的概率是 0.8,损失就是-log(0.8) ≈ 0.223。如果模型很自信地预测错了,比如把 3 的概率给成 0.01,损失就是-log(0.01) ≈ 4.6,惩罚明显更重。这就是交叉熵比均方误差更适合分类任务的原因——它对“自信地犯错”惩罚得更狠,梯度信号也更强。

3. 反向传播:梯度到底是怎么一层层算出来的

这一节是全文的核心,也是最需要动笔推导的地方。我的建议是,别指望看一遍就记住,拿张纸跟着推一遍,再把公式翻译成代码,最后用数值梯度做对拍,这套流程走下来,你才算真正掌握了。

先回顾前向的计算链路:输入 x 经过第一层得到 z1,过 ReLU 得到 a1,再经过第二层得到 z2,softmax 加交叉熵得到标量损失 L。反向传播要回答的问题是:L 对每一个参数 W1、b1、W2、b2 的偏导是多少?根据链式法则,我们从 L 开始往回走,每一步乘上当前环节的局部导数。

第一步,L 对 z2 的梯度。这一步的结果非常漂亮:它就是预测概率向量减去真实标签的独热向量,即dz2 = p - y_onehot。这个结论可以直接用,但建议你至少自己推一次,因为它是整个反向传播里唯一一个“跳过求导直接用结论”的地方,理解了它对后面所有环节的直觉都有帮助。对于一个批次,我们把每个样本的dz2加起来再除以批次大小,得到平均梯度,形状是批次大小×10。

第二步,L 对 W2 和 b2 的梯度。因为z2 = a1 @ W2 + b2,根据矩阵求导规则,dW2 = a1.T @ dz2,db2就是dz2在样本维度上求和。形状检查:a1 是 N×128,dz2 是 N×10,转置相乘得到 128×10,正好和 W2 同形,对上了。这一步也是很多人第一次写错的地方,主要错在忘了转置或者忘了求平均。

3.1 链式法则继续往上传

继续往回传到隐藏层。a1 对 z2 的贡献通过 W2 传递,所以da1 = dz2 @ W2.T,形状 N×128。接下来要过 ReLU:ReLU 的导数是,当输入大于 0 时导数为 1,否则为 0。所以dz1 = da1 * (z1 > 0),这里的乘是逐元素乘,(z1 > 0)生成一个同形状的布尔掩码。这一步有个常见误区:用的是z1 > 0而不是a1 > 0。虽然数学上 a1 就是 ReLU(z1),用哪个判断结果一样,但语义上应该用 z1,因为 ReLU 在 0 点不可导,实现上通常取 0 那侧的导数,用 z1 判断更清晰。

然后是 W1 和 b1:dW1 = x.T @ dz1,db1 = dz1在样本维度求和,同样都要除以批次大小。形状检查:x 是 N×784,dz1 是 N×128,转置相乘得 784×128,和 W1 同形。到这里四个梯度全部算完,每个都和对应的参数矩阵形状一致,这是写代码时最应该先验证的一件事。

我强烈建议你在实现完之后做一次梯度检查。方法是:对某一个参数,比如 W1 的某个元素,把它加上一个极小的量 ε 算出损失 L+,减去 ε 算出 L-,用(L+ - L-) / (2ε)作为数值梯度,然后和你反向传播算出来的解析梯度比较。如果相对误差在 1e-7 量级,说明实现正确;如果差得远,多半是某一步漏了转置、漏了除法,或者激活函数的导数写错了。这个检查我每次写新层都会做,看似麻烦,其实省下大量排查时间。

3.2 一个最小可用的梯度检查脚本

梯度检查的关键是“小”:用极小的网络和极小的批量,几行代码就能跑完。我通常把输入维度降到 4,隐藏层降到 3,输出 2 类,批次 2 个样本,这样肉眼都能核对。参数随机初始化,然后逐个元素做中心差分。这里 ε 不能太大也不能太小,太大会引入截断误差,太小会被浮点精度淹没,1e-4 是个常用的值。用双精度浮点的话,相对误差能压到 1e-8 以下,如果你的实现误差在 1e-4 以上,基本可以断定有问题。

做检查时有两个细节要注意。一是不要在训练后的参数上检查,因为训练后的参数可能让 ReLU 大面积失活,梯度全是 0,数值梯度也是 0,看起来“对上了”但其实没验证到东西。用随机初始化的参数,保证激活值分布正常。二是要检查多个位置,最好每个参数矩阵随机抽几个元素,而不是只查一个,避免碰巧。

这个脚本还有个小用途:当你怀疑训练不收敛是梯度算错导致的时候,跑一遍它就能排除这个可能性。我自己遇到过好几次“损失不降”,第一反应都是怀疑梯度公式,结果检查完发现梯度是对的,问题出在学习率或者数据上。所以这个检查不是一次性的,它是你排查问题的第一道筛子。

3.3 三种梯度下降形态与批次大小的取舍

梯度下降有三个常见变体,区别在于每次更新用多少样本算梯度。全量梯度下降用全部训练样本算一次梯度再更新一次,方向最准,但一次更新要等全部数据算完,速度慢,而且大数据集根本放不下内存。随机梯度下降每次只用一个样本,更新极快,但梯度噪声大,损失曲线抖得厉害。小批量梯度下降取中间值,每次用几十到几百个样本,既保留了向量化计算的效率,又让梯度有一定稳定性,是目前最常用的做法。

批次大小怎么选?我的经验是这个任务上 32 到 128 都可以,64 是个不错的默认值。批次太小,比如 8,梯度噪声大,需要更小的学习率才能稳住;批次太大,比如 1024,每个 epoch 的更新次数变少,收敛变慢,而且更容易陷入比较平坦的区域。有个粗略的规律是,批次翻倍时学习率可以适当放大一点,因为梯度估计更准了。但这个规律不是铁律,还是要看你自己的损失曲线。

还有一个容易被忽略的点:批次内的样本顺序要不要打乱。答案是必须要。如果每个 epoch 都按固定顺序喂数据,模型会学到顺序上的相关性,比如连续几张都是数字 7,梯度就会连续往同一个方向推,训练效果变差。打乱很简单,每个 epoch 开始前对整个索引数组做一次随机置换就行。这个操作成本很低,但对训练稳定性帮助明显,我早期没打乱的时候,损失曲线呈现出规律的周期性波动,加上打乱之后就平滑多了。

4. 完整训练循环的实现与调参实录

理论讲完,接下来是把它落成能跑的代码。我会按训练脚本的执行顺序讲:初始化、前向、损失、反向、更新,每个环节说清楚我在实际调试中改过什么、为什么这么改。

参数初始化是第一个决策。最开始我图省事,把所有参数初始化为 0,结果训练完全不动,损失一直停在log(10) ≈ 2.3附近。原因很直白:如果所有权重都是 0,那么同一层的所有神经元输出的值完全相同,反向传播算出来的梯度也完全相同,更新之后它们还是完全一样,网络永远学不到不同的特征。这叫对称性问题,是初始化必须打破的。正确做法是随机初始化,让每个神经元的初始状态有差异。

那随机初始化的尺度怎么定?全用标准正态分布N(0, 1)也不行。如果输入是 784 维,每维都在 0 到 1 之间,那么加权和的数量级大概在sqrt(784) ≈ 28左右,经过 ReLU 之后激活值的方差会很大,深层传递下去梯度容易爆炸或者消失。常用的做法是 He 初始化,针对 ReLU 激活,标准差取sqrt(2 / fan_in),其中fan_in是本层的输入维度。第一层就是sqrt(2/784) ≈ 0.0505,第二层是sqrt(2/128) ≈ 0.125。偏置全部初始化为 0 就行,因为对称性已经被权重打破了。

4.1 学习率:从 0.5 一路调到 0.05 的过程

学习率是我调得最久的一个参数,也是最影响结果的。我最开始随手设了 0.5,跑起来损失确实在动,但抖得非常厉害,有时候一轮之内损失能从 0.5 跳到 1.2 再跳回来,准确率卡在 60% 上不去。这就是学习率过大的典型表现:每次更新迈的步子太大,直接跨过了最优点,在谷底两侧来回弹。后来降到 0.1,曲线平滑了不少,但偶尔还是会震荡。最后定在 0.05,损失单调下降,收敛也够快,10 个 epoch 就能到 96% 以上。

我也试过小学习率,0.001 那个量级,损失曲线非常平滑,但下降极慢,跑了 20 个 epoch 才到 90%,性价比太低。所以学习率的选择本质上是在“快但可能不稳”和“稳但慢”之间找平衡。我给你一个实操建议:先用 0.1 跑 3 到 5 个 epoch 看损失趋势,如果损失在前几百次更新里没有明显下降,说明太小,往上调;如果损失剧烈震荡或者出现nan,说明太大,往下调一个数量级。这样两三轮就能锁定一个可用的值。

还有个小技巧是学习率衰减。训练后期,模型接近最优点,这时候还保持大步子容易在最优点附近来回跳。做法是每隔几个 epoch 把学习率乘个 0.5 或者 0.8。我实测下来,从 0.1 开始,每个 epoch 乘以 0.9,最终准确率比固定学习率高了大概 0.3 个百分点。提升不算大,但代码改动很小,加几行就够,值得一试。

4.2 训练脚本的骨架与关键实现

我把训练循环的骨架写出来,重点是形状和顺序,你可以对照自己的实现。整体思路是:每个 epoch 打乱索引,按批次切分,对每个批次做一次前向、损失、反向、更新。所有的矩阵运算都用数组库完成,不用任何自动求导。

import numpy as np # 假设 x_train: (N, 784),值已归一化到 0-1;y_train: (N,),整数标签 N, D = x_train.shape H, C = 128, 10 # He 初始化 rng = np.random.default_rng(42) W1 = rng.normal(0, np.sqrt(2.0 / D), size=(D, H)) b1 = np.zeros(H) W2 = rng.normal(0, np.sqrt(2.0 / H), size=(H, C)) b2 = np.zeros(C) lr = 0.05 batch_size = 64 epochs = 15 for epoch in range(epochs): idx = rng.permutation(N) x_shuf, y_shuf = x_train[idx], y_train[idx] total_loss = 0.0 for start in range(0, N, batch_size): xb = x_shuf[start:start + batch_size] yb = y_shuf[start:start + batch_size] n = xb.shape[0] # 前向 z1 = xb @ W1 + b1 a1 = np.maximum(z1, 0) z2 = a1 @ W2 + b2 z2_shift = z2 - z2.max(axis=1, keepdims=True) exp_z = np.exp(z2_shift) probs = exp_z / exp_z.sum(axis=1, keepdims=True) # 交叉熵损失(用整数标签直接索引) loss = -np.log(probs[np.arange(n), yb] + 1e-12).mean() total_loss += loss * n # 反向 dz2 = probs.copy() dz2[np.arange(n), yb] -= 1.0 dz2 /= n dW2 = a1.T @ dz2 db2 = dz2.sum(axis=0) da1 = dz2 @ W2.T dz1 = da1 * (z1 > 0) dW1 = xb.T @ dz1 db1 = dz1.sum(axis=0) # 更新 W1 -= lr * dW1 b1 -= lr * db1 W2 -= lr * dW2 b2 -= lr * db2 print(f"epoch {epoch+1}, loss {total_loss / N:.4f}")

这段代码有几个地方值得单独说。probs.copy()那一步,如果直接写dz2 = probs,后面减 1 会改掉probs本身,虽然这里损失已经算完了不影响,但如果你要在日志里记录概率分布,就会拿到被污染的数据。这种引用问题在数组库的代码里很常见,我建议凡是“要基于某个数组做修改”的地方,一开始就copy(),代价很小。另一处是z1 > 0生成的布尔掩码,乘以浮点数组时会自动转成 0 和 1,这一步在语义上是 ReLU 的导数,别写成a1 > 0虽然结果一样,但读代码的人容易困惑。

4.3 训练结果与过程观察

跑完 15 个 epoch,训练损失从初始的 2.3 左右降到 0.08 上下,训练集准确率大概 97.5%,测试集在 96.5% 到 97.2% 之间浮动。这个结果和官方文档里那些用同样结构跑出来的数字基本吻合,说明实现没问题。整个过程在普通笔记本上单核跑,每个 epoch 大概十秒出头,总共三分钟左右,改一次代码等几十秒就能看到反馈,这个节奏很适合反复试错。

观察损失曲线很有意思。前两个 epoch 损失下降非常快,从 2.3 掉到 0.4,这一段主要模型在学最粗糙的特征,比如哪些像素区域整体更亮。第三到第六个 epoch 下降放缓,从 0.4 到 0.15,这段在学更细的模式。第七个 epoch 之后基本收敛,损失在 0.08 附近小幅波动。如果你只看到前面下降快就停下来,会觉得“差不多了”,实际上后面这段才是准确率从 90% 爬到 97% 的关键期。

我还做过一个对比实验,把隐藏层从 128 降到 32,其他不变。结果训练损失能降到 0.2 左右,测试准确率大概 94%,比 128 低了三个点。再降到 8,准确率掉到 90% 出头。这个对比挺有说服力的,它说明模型容量确实制约了表达力,而且这个制约体现在“学不到更细的模式”上,不是训练不够。反过来把隐藏层加到 512,准确率只高了 0.2 个点,训练时间却翻倍,边际收益很低。

5. 踩过的坑与排查清单

这一节是我觉得最有价值的部分,因为下面这些问题几乎都是我实际撞过的,而且不少在教程里根本不会提。我把它们按现象归类,你可以当成一个速查表用。

5.1 损失不降的常见病因

最典型的现象是损失卡在 2.3 附近不动,正好是log(10),说明模型输出的概率接近均匀分布,等于什么都没学。第一个要查的是初始化和标签对齐。我遇到过一次,数据加载时把图像和标签的顺序搞错了,模型在学随机配对,当然学不动。检查方法是打印前几个样本的图像和标签,肉眼确认一下,这个动作花不了一分钟,但能排除掉最蠢的问题。

第二个要查的是梯度是否真的非零。在第一次更新之前打印梯度的范数,如果全是 0,说明反向链路断了,可能是某一层用了不可导的激活,或者掩码写反了(比如写成z1 < 0)。如果梯度范数极大,比如上万,那多半是没做归一化或者学习率太大导致数值爆炸。正常情况下,第一层的梯度范数应该在千分之几到百分之几这个量级。

第三个要查的是学习率。前面说过,太大导致震荡,太小导致几乎不动。区分方法是看损失曲线的前几十次更新:完全平的说明太小或者梯度为 0;大幅上下跳的说明太大。还有一个隐蔽的情况是学习率设成了整数除法里的 0,比如在某些语言里1/100得 0,虽然用浮点写不会出这问题,但如果你是动态计算学习率的,记得检查一下。

第四个是数值溢出。如果你的损失偶尔变成nan,基本就是 softmax 或者对数运算的数值问题。确认有没有减去最大值,有没有给对数加保护值。有个简单粗暴的定位方法:在损失计算前打印z2的最大值,如果超过 700,指数运算就危险了。

5.2 过拟合、欠拟合与验证集的作用

训练准确率 97.5%、测试 96.8%,这个差距说明泛化还行,略微过拟合但不严重。判断过拟合的标准很简单:训练集表现远好于验证集,比如训练 99%、验证 92%,那就是模型把训练样本背下来了。这个任务上过拟合不算严重,因为样本量够,但如果你把隐藏层加到 1024 并且训练很多轮,就会看到明显的过拟合。

应对过拟合最直接的办法是加正则化。L2 正则就是在损失里加上权重平方和的一部分,反向传播时在梯度里对应加上权重本身乘以系数。这个改动的实现很简单,但系数的选择要试,太大导致欠拟合,太小没效果。另一个办法是 Dropout,训练时随机把一部分神经元的输出置零,逼网络学到更鲁棒的特征。我试过在全连接层后面加 0.2 的 Dropout,测试准确率提升了大概 0.2 个点,但训练变慢了一些。

验证集的划分是必须的。如果你的调参决策是基于测试集的,那测试集就不再是“没见过的数据”了,你得到的准确率会偏乐观。正确做法是从训练集里切出 10% 左右做验证,所有超参数在验证集上定,最后再在测试集上跑一次作为最终结果。我早期偷懒直接用测试集看效果,结果调完参发现实际部署时效果比预期低一截,就是因为这个。

5.3 我的排查速查表与实操习惯

下面这张表是我这些年攒下来的,遇到问题先对照着过一遍,大部分情况能快速定位。

现象最可能的原因快速验证方法
损失卡在 2.3 不动初始化全 0、标签错位、梯度为 0打印梯度范数、核对标签
损失剧烈震荡学习率过大学习率除以 10 再跑
损失变成 nansoftmax 溢出、学习率爆炸打印 z2 最大值、检查是否减最大值
训练准确率高、验证低过拟合加 L2 或 Dropout、减模型规模
训练和验证都上不去欠拟合、模型容量不足加宽隐藏层、加训练轮数
准确率停在 10% 附近输出层或损失实现错误检查标签与输出维度是否对应
训练速度异常慢没有向量化、批次过小检查是否用了循环代替矩阵运算

除了表格,我还有两个习惯值得分享。一是固定随机种子。每次实验前把种子设死,这样两次运行的差异就只来自你改的那个变量,对比才有意义。我见过有人改了一个参数,结果三次运行三个结果,分不清是参数的作用还是随机性的作用。二是记录每次实验的配置和结果,哪怕只是记在文本文件里。人的记忆不可靠,一周之后你肯定记不住“上次那个 96.8% 用的是多少学习率”。我自己是用一个简单的表格,记日期、改动内容、最终准确率、备注,积累几十条之后回头看,能发现很多规律。

最后说个关于调试节奏的体会。手写这套东西最容易犯的错是“一次改太多”,比如同时改学习率、改隐藏层大小、改初始化方式,然后结果变好了,你根本不知道是哪个改动的功劳。我的建议是每次只动一个变量,跑完看结果,记录下来,再动下一个。这个过程慢,但每一步都是确定的,积累下来的直觉才可靠。真正快的不是一次改十个参数碰运气,而是对每个参数的作用心里有数,一次就能改对方向。