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再经过第二层得到 z2softmax 加交叉熵得到标量损失 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 dz2db2就是dz2在样本维度上求和。形状检查a1 是 N×128dz2 是 N×10转置相乘得到 128×10正好和 W2 同形对上了。这一步也是很多人第一次写错的地方主要错在忘了转置或者忘了求平均。3.1 链式法则继续往上传继续往回传到隐藏层。a1 对 z2 的贡献通过 W2 传递所以da1 dz2 W2.T形状 N×128。接下来要过 ReLUReLU 的导数是当输入大于 0 时导数为 1否则为 0。所以dz1 da1 * (z1 0)这里的乘是逐元素乘(z1 0)生成一个同形状的布尔掩码。这一步有个常见误区用的是z1 0而不是a1 0。虽然数学上 a1 就是 ReLU(z1)用哪个判断结果一样但语义上应该用 z1因为 ReLU 在 0 点不可导实现上通常取 0 那侧的导数用 z1 判断更清晰。然后是 W1 和 b1dW1 x.T dz1db1 dz1在样本维度求和同样都要除以批次大小。形状检查x 是 N×784dz1 是 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-1y_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(axis1, keepdimsTrue) exp_z np.exp(z2_shift) probs exp_z / exp_z.sum(axis1, keepdimsTrue) # 交叉熵损失用整数标签直接索引 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(axis0) da1 dz2 W2.T dz1 da1 * (z1 0) dW1 xb.T dz1 db1 dz1.sum(axis0) # 更新 W1 - lr * dW1 b1 - lr * db1 W2 - lr * dW2 b2 - lr * db2 print(fepoch {epoch1}, 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% 用的是多少学习率”。我自己是用一个简单的表格记日期、改动内容、最终准确率、备注积累几十条之后回头看能发现很多规律。最后说个关于调试节奏的体会。手写这套东西最容易犯的错是“一次改太多”比如同时改学习率、改隐藏层大小、改初始化方式然后结果变好了你根本不知道是哪个改动的功劳。我的建议是每次只动一个变量跑完看结果记录下来再动下一个。这个过程慢但每一步都是确定的积累下来的直觉才可靠。真正快的不是一次改十个参数碰运气而是对每个参数的作用心里有数一次就能改对方向。