
前四章把数据读进来、把形状理顺、把前向计算的几行代码写完到第五章才算真正碰到训练这件事。很多人在这一关卡住不是因为公式难而是因为第一次看到屏幕上那行数字时完全不知道该拿它怎么办。我印象很深的一次两层全连接网络第一次跑起来损失值从 2.302 开始十个 epoch 过去还是 2.301 上下晃验证集准确率稳定在 0.098——比瞎猜还差一点点十类随机猜的期望正好是 10%。我盯着终端看了半小时第一反应是标签和图片对错了位第二反应是梯度算错了符号。事后复盘问题出在输入没有做归一化像素还停留在 0 到 255 的原始量级同样的学习率作用在那种尺度的梯度上等于拿消防水管浇花。这件事让我意识到梯度下降算法本身只有一行公式但让它真正把一堆随机初始化的权重推到能认出 0 到 9的位置需要一整套配套的判断损失函数怎么选、反向传播的每一项对应哪个张量、批大小和学习率怎么配、损失曲线长什么样才算健康。这篇内容就是围绕这条线展开的。一个输入 784 维、隐层 128 个 ReLU 单元、输出 10 类的两层网络参数量大约 10 万个全部用最基础的数组运算从零实现不依赖任何自动求导。适合已经能写出前向计算、但对反向传播和参数更新这一套还没有完整手感的读者如果你已经能熟练训练模型却总是调不好下面关于学习率和批大小的几节也值得看看。1. 准确率死死卡在 10%第五章真正的关卡在哪1.1 那行 2.302 到底在说什么十类分类任务刚开始训练时如果输出层的权重接近零、偏置也是零那么 softmax 的输出对每个样本都是均匀的 0.1。此时交叉熵损失等于 -ln(0.1)也就是 2.302585。这个数字不是巧合它是模型还什么都不知道的基线值。理解这一点非常重要你第一次运行时看到的损失如果不是 2.302 附近说明前向计算或者标签索引已经出问题了这时候调学习率毫无意义。我后来把这当成一个固定的自检点。跑训练之前先做一次零步验证不更新任何参数直接算一遍整个训练集上的平均损失看是不是 2.30 左右。如果不是往下查三件事——标签是不是从 0 开始编号、softmax 是不是按行归一化、交叉熵是不是取到了正确的那个位置。这三条里任何一条错了损失都会变成别的数值比如标签按 1 到 10 编号时会出现越界或者错位一位的情况损失看起来也在降但准确率永远上不去。除了 2.302 这个锚点还有一个基线值值得记住准确率 10%。如果训练跑完验证准确率停在 0.09 到 0.11 之间说明网络等效于在瞎猜。这时候不要去怀疑梯度下降收敛慢一定是某个环节断路了——最常见的是激活函数写成了恒等映射但权重初始化又是全零或者反向传播时梯度被乘了一个零。1.2 梯度下降在这个流程里的位置很多人把梯度下降和反向传播混为一谈其实它们是两件事。反向传播负责算梯度回答每个参数往哪个方向调能让损失变小、变小的速度有多快梯度下降负责用梯度回答知道了方向之后这一步迈多大。前者是链式法则的工程实现后者是一个再朴素不过的迭代策略$$W \leftarrow W - \eta \cdot \frac{\partial L}{\partial W}$$这里的 $\eta$ 就是学习率。整章后面所有的调参纠结本质上都在纠结这个 $\eta$ 和它周围的那几个量。我习惯用一个比喻跟人解释参数是一群人站在浓雾里的山坡上看不见谷底在哪只能用脚感受脚下哪边更陡梯度然后朝下坡方向迈一步学习率决定步子多大。步子太小走一晚上也到不了山脚步子太大直接跨过谷底冲到对面山坡来回震荡甚至越走越高。这个比喻不能解释一切但它能解释新手遇到的八成问题。1.3 为什么这里用两层而不是一层线性模型直接接 softmax本质上还是在做十个线性打分它能学到的东西非常有限。手写数字里7和1靠几笔像素的分布就能分开但3和8、4和9这类样本需要一点组合特征——比如某个区域同时有横笔画和闭合环。加一层非线性激活的隐层等于让网络自己去构造这些组合特征后面的线性分类器再用它们打分。参数量算一下输入层到隐层是 $784 \times 128 128 100{,}480$隐层到输出层是 $128 \times 10 10 1{,}290$合计约 10.2 万。这个量级的好处是训练极快用普通笔记本的 CPU 跑一个 epoch 也就几秒到十几秒调参反馈周期短非常适合把梯度下降的每个细节看清楚。等这套流程摸透了再换更大的网络你会发现要操心的东西其实是同一批。2. 把认数字翻译成可求导的数学输出层与损失函数的选型2.1 输出层为什么是 softmax 而不是直接回归有一种很直觉的做法让网络输出十个数第几类就把第几个位置标成 1其余标 0然后用均方误差去拟合。这个思路能跑但它有两个明显的毛病。第一个毛病是量纲不对。均方误差衡量的是输出值和目标值的平方距离而分类任务关心的是正确类别的概率是不是最大。这两件事不等价。一个样本真实标签是 3网络输出 (0.9, 0.05, 0.05, ...) 和 (0.4, 0.35, 0.25, ...) 在谁最大这件事上一个赢一个输但后者在均方误差下未必受到足够重的惩罚。第二个毛病出在梯度上。softmax 加交叉熵这个组合对输出层输入的梯度会化简成一个非常干净的形式$\hat{p} - y$也就是预测概率减去 one-hot 标签。而 softmax 加均方误差梯度里会多出一个 $\hat{p}(1-\hat{p})$ 形状的因子当预测已经非常肯定概率接近 0 或 1却预测错了的时候这个因子趋近于零梯度被压得很小学不动。这就是典型的错得很离谱反而学得慢。所以这一章的实现里输出层固定为 softmax损失固定为交叉熵。这不是口味问题是数学上更顺。2.2 两种损失在梯度形式上的差别把差别做成一张表更直观。下表里的 $\hat{p}$ 是 softmax 输出向量$y$ 是 one-hot 标签$z$ 是输出层的输入softmax 之前的那个向量。对比项softmax 交叉熵softmax 均方误差损失定义$-\frac{1}{N}\sum_i \log \hat{p}_{i,y_i}$$\frac{1}{2N}\sum_i \lVert \hat{p}_i - y_i \rVert^2$对 $z$ 的梯度$(\hat{p} - y)/N$含 $\hat{p}(1-\hat{p})$ 因子的形式饱和时的表现梯度仍然充足梯度被压制收敛变慢数值稳定性需要对 log 加极小值相对温和适用场景互斥多分类回归、多标签看清这张表就明白为什么几乎所有分类教程都用交叉熵。它的梯度只和预测与真实的差成正比预测错得越多梯度越大修得越快这是非常符合直觉的行为。2.3 一个必须写的数值细节softmax 先减最大值softmax 的定义是 $e^{z_k} / \sum_j e^{z_j}$。数学上没问题但代码里直接算会出事。如果某个 $z_k$ 是 800$e^{800}$ 直接溢出成 inf除出来就是 nan然后整个权重矩阵被污染成 nan损失从 2.3 变成 nan训练彻底废掉。标准做法是先减去这一行里的最大值$$\hat{p}_k \frac{e^{z_k - \max_j z_j}}{\sum_j e^{z_j - \max_j z_j}}$$减掉一个常数不改变 softmax 的结果分子分母同乘 $e^{-\max}$ 约掉了但保证了最大的那一项指数是 $e^0 1$其余都小于 1不会溢出。这个改动只有一行代码但不写它训练偶尔会在某个 batch 上突然崩掉而且崩得很随机极难排查。同理算交叉熵时log(0)会得到 -inf所以取 log 的位置要加一个极小值比如1e-12。这个保护不会影响正常情况下的数值因为正确类的概率通常远大于 1e-12但能避免极端样本把整个 batch 的损失拉成 inf。提示这两个数值保护是写了看不出效果、不写迟早出事的典型。凡是涉及 exp 和 log 的地方默认加上保护别等出问题再加。3. 两层网络的反向传播链式法则逐项拆解3.1 前向计算对应的四个中间量先把前向过程写成矩阵形式。设一个 batch 的输入是 $X$形状 $(N, 784)$那么$$z_1 XW_1 b_1,\quad a_1 \max(z_1, 0),\quad z_2 a_1W_2 b_2,\quad \hat{p} \text{softmax}(z_2)$$这四个量里$X$、$z_1$、$a_1$、$\hat{p}$ 在反向传播时都要用到所以前向函数必须把它们缓存下来。很多人图省事只返回 $\hat{p}$结果反向传播时要么重算一遍浪费一倍时间要么到处传参传得乱七八糟。我的习惯是前向函数返回(输出, cache)两个东西cache 里塞一个元组反向函数只接收 cache 和标签。ReLU 的导数 $a_1 \max(z_1, 0)$ 对应的是$z_1 0$ 的位置导数为 1否则为 0。代码里就是(z1 0)这个布尔矩阵转成浮点直接乘上去。注意是拿 $z_1$ 判断不是拿 $a_1$ 判断——虽然在这条式子里 $a_1 0$ 和 $z_1 0$ 等价但在别的地方容易混淆养成用 $z$ 判断的习惯更稳妥。3.2 从 dz2 倒推 dz1形状对齐是第一道保险反向传播最难的不是推导是形状。矩阵乘法的维度一旦对不上要么报错还算幸运要么广播出一个形状诡异的结果悄悄算错。先算输出层。损失对 $z_2$ 的梯度是 $(\hat{p} - y)/N$形状 $(N, 10)$。然后$dW_2 a_1^\top \cdot dz_2$形状 $(128, N) \times (N, 10) (128, 10)$和 $W_2$ 一致对。$db_2 dz_2$ 按列求和形状 $(10,)$和 $b_2$ 一致对。$da_1 dz_2 \cdot W_2^\top$形状 $(N, 10) \times (10, 128) (N, 128)$和 $a_1$ 一致对。再往隐层传。ReLU 把梯度按掩码过滤$dz_1 da_1 \odot \mathbb{1}(z_1 0)$形状仍是 $(N, 128)$。然后$dW_1 X^\top \cdot dz_1$形状 $(784, N) \times (N, 128) (784, 128)$和 $W_1$ 一致。$db_1 dz_1$ 按列求和形状 $(128,)$。每一步都做一次形状检查是我调试反向传播最有效的手段。写完一行dW2 a1.T dz2立刻在旁边写注释# (128,N)(N,10) - (128,10)不要相信记忆。我见过太多次把转置放错位置结果网络也能跑、损失也在降因为某些情况下形状恰好能广播只是准确率永远比别人低三四个点。还有一个常见错误是忘记除以 $N$。交叉熵已经按样本平均了所以梯度也要除以 batch 大小。如果漏掉这一步梯度会大 $N$ 倍$N64$ 时就是 64 倍学习率必须相应缩小 64 倍才能不崩很多人调不出结果就是因为这里少了个除法然后靠反复调学习率硬凑。3.3 参数初始化不是小事全零初始化走不通如果 $W_1$、$W_2$ 全初始化为零会发生什么前向时隐层所有单元的输出完全一样反向时它们的梯度也完全一样更新之后仍然一样。这叫对称性无法打破128 个隐层单元实际只相当于 1 个网络容量被浪费掉准确率大概停在 92% 左右就上不去了。标准做法是用带尺度的高斯初始化。对 ReLU 激活常用的是 He 初始化标准差取 $\sqrt{2/n_{in}}$$W_1$均值 0标准差 $\sqrt{2/784} \approx 0.0505$形状 $(784, 128)$$W_2$均值 0标准差 $\sqrt{2/128} \approx 0.125$形状 $(128, 10)$偏置全部初始化为 0这个 $\sqrt{2/n_{in}}$ 的 2 是从 ReLU 把负半轴全部截断这个事实推出来的——它让每层输出的方差大致保持不变前向不会一层层放大或衰减反向也不会梯度爆炸或消失。经验上初始化尺度偏小会导致训练前期损失下降极慢像在爬偏大会导致第一个 batch 就 nan。注意随机种子要固定下来。同一份代码同一个种子跑两次损失曲线应该完全重合如果不能重合说明有某个地方用了没设种子的随机源后面所有的对比实验都不可信。4. 一个 epoch 的完整骨架与可运行脚本4.1 批大小、shuffle 与梯度估计的方差严格意义的梯度下降要用全部训练样本算梯度6 万张图一次性算完内存扛得住但速度慢而且一个 epoch 只能更新一次参数收敛极慢。另一个极端是每次只用一个样本随机梯度下降更新频繁但梯度噪声极大损失曲线抖得像心电图。实践中用的是小批量一批 32 到 256 个样本。这么做有三重好处矩阵运算能吃到向量化的性能红利梯度是这一批样本的平均比单样本稳得多一个 epoch 内能更新几百上千次收敛快。批大小本身就是一个超参数后面第 5 节专门讲。每个 epoch 开始前必须打乱样本顺序。如果不打乱同一批里可能连续几十个都是同一个数字梯度方向会严重偏向这一类训练过程变成学一阵 0、再学一阵 1收敛慢且不稳。打乱的正确做法是用一个随机排列去索引而不是把数据本身搬来搬去idx rng.permutation(n) X_shuf, y_shuf X[idx], y[idx]这里有个内存上的小陷阱X[idx]会复制一份完整数据6 万乘 784 的 float32 大约 180MB每 epoch 复制一次可以接受但如果数据集更大就要考虑用索引分批取别一次性复制。4.2 完整可运行的训练脚本下面这份代码是这一章的核心从初始化到训练循环全部包含只依赖数组运算库。import numpy as np def init_params(n_in784, n_hidden128, n_out10, seed42): rng np.random.default_rng(seed) W1 rng.normal(0, np.sqrt(2.0 / n_in), (n_in, n_hidden)).astype(np.float32) b1 np.zeros(n_hidden, dtypenp.float32) W2 rng.normal(0, np.sqrt(2.0 / n_hidden), (n_hidden, n_out)).astype(np.float32) b2 np.zeros(n_out, dtypenp.float32) return [W1, b1, W2, b2] def forward(X, params): W1, b1, W2, b2 params z1 X W1 b1 # (N,128) a1 np.maximum(z1, 0.0) # ReLU z2 a1 W2 b2 # (N,10) z2 z2 - z2.max(axis1, keepdimsTrue) # 数值稳定 e np.exp(z2) p e / e.sum(axis1, keepdimsTrue) return p, (X, z1, a1, p) def cross_entropy(p, y): n p.shape[0] return -np.log(p[np.arange(n), y] 1e-12).mean() def backward(cache, y, params): X, z1, a1, p cache _, _, W2, _ params n X.shape[0] onehot np.zeros_like(p) onehot[np.arange(n), y] 1.0 dz2 (p - onehot) / n # (N,10) dW2 a1.T dz2 # (128,10) db2 dz2.sum(axis0) # (10,) da1 dz2 W2.T # (N,128) dz1 da1 * (z1 0) # (N,128) dW1 X.T dz1 # (784,128) db1 dz1.sum(axis0) # (128,) return [dW1, db1, dW2, db2] def accuracy(X, y, params): p, _ forward(X, params) return float((p.argmax(axis1) y).mean()) def train(X, y, X_val, y_val, epochs10, batch_size64, lr0.1, seed42): params init_params(seedseed) rng np.random.default_rng(seed) n X.shape[0] for ep in range(epochs): idx rng.permutation(n) Xs, ys X[idx], y[idx] total 0.0 for s in range(0, n, batch_size): xb, yb Xs[s:sbatch_size], ys[s:sbatch_size] p, cache forward(xb, params) total cross_entropy(p, yb) * xb.shape[0] grads backward(cache, yb, params) for k in range(4): params[k] - lr * grads[k] print(fepoch {ep1:2d} loss {total/n:.4f} val_acc {accuracy(X_val, y_val, params):.4f}) return params调用前记得做归一化把像素从 0 到 255 压到 0 到 1X_train X_train.astype(np.float32) / 255.0 X_val X_val.astype(np.float32) / 255.0 params train(X_train, y_train, X_val, y_val, epochs10, batch_size64, lr0.1)十轮跑完验证准确率大致落在 96% 到 97.3% 之间具体数字取决于随机种子和打乱顺序上下浮动零点几个点都正常。4.3 损失曲线应该长什么样健康的训练曲线有三个特征第一个 epoch 内损失从 2.30 快速掉到 1.0 以下这一步主要是在学每个类别的先验分布第三到第五个 epoch 开始进入 0.3 以下的慢速下降区曲线整体单调下行允许有小的波动但不能反复横跳。如果损失曲线是一条几乎水平的线先看学习率是不是太小比如用了 1e-5如果是锯齿状上下大幅震荡八成是学习率太大或者输入没归一化如果是前期降得很好第六轮之后开始缓慢回升那要看验证集指标有没有同步变差如果变差就是过拟合。我习惯把训练损失和验证准确率一起打印而不是只看其中一个。只看损失容易误判——损失在降但准确率不动说明模型在学的东西和分类对不对关系不大这时候要去检查是不是有很多样本的预测概率从 0.51 挪到 0.52永远跨不过决策边界。5. 学习率与批大小两个互相牵制的旋钮5.1 学习率大了小了分别是什么症状学习率是这套系统里最敏感的参数。同一份代码$\eta$ 取 0.001 和取 1.0 完全是两种命运。我做过的对比结果大致如下批大小 64十轮其余设置一致学习率第一轮结束损失第十轮验证准确率现象描述0.00012.280.31几乎不动十轮白跑0.0011.420.86在降但明显没跑够0.010.420.94稳但偏慢0.10.210.966这个配置下的甜点区0.50.350.951能收敛波动变大1.02.290.10直接崩损失回到基线附近表里的 1.0 那一行很有代表性损失不是缓慢上升而是直接跳回 2.30然后卡在那里。原因是这一步迈得太大把权重推到了一个饱和区softmax 输出变成了极端的 one-hot错误样本的梯度反而趋近于零网络再也爬不出来。这种崩掉和没学会在外观上很像都是损失停在 2.3 附近区别是崩掉之前会有一次突然的跳动。实用的搜索方法是按 10 倍量级往两边试比如从 0.1 开始试 1.0、0.01、0.001找到能稳定下降的最大值然后退回一个量级。不要做精细网格搜索这个阶段不值得。5.2 批大小与学习率之间的经验换算批大小一变最优学习率也跟着变。原因很直白批大小是 $B$ 时梯度是 $B$ 个样本梯度的平均它的方差大约是单样本的 $1/B$。批越大梯度越准方向越可靠你就可以迈更大的步子。工程上常用的一条经验是线性缩放批大小从 64 翻到 2564 倍学习率也相应乘以 4。这条规则在小批量范围内近似成立批特别大时会失效超过某个点收益递减但对这一章 10 万参数的小网络完全够用。实际调的时候我会固定一个组合去看而不是孤立地调一个。下面这张表是我认为对这份代码比较稳的几组批大小学习率单轮耗时相对十轮后准确率160.03慢0.962640.1中0.9662560.4快0.96010241.0最快0.943可以看到批大小不是越大越好。批太小时梯度噪声大但噪声本身有正则化效果泛化有时反而更好批太大时虽然每轮跑得快但更新次数少同样轮数下参数更新步数少了十几倍收敛不充分。64 到 256 之间通常是性价比最高的区间。5.3 加上动量和学习率衰减后的实际收益朴素梯度下降有个毛病在峡谷形的损失面上它会在谷壁之间来回横跳沿谷底方向反而前进很慢。动量法把历史梯度累积起来相当于给更新加了个惯性横向摆动被抵消纵向速度被放大v [np.zeros_like(p) for p in params] beta 0.9 # 每个 batch 内 for k in range(4): v[k] beta * v[k] grads[k] params[k] - lr * v[k]这段改动只有四行实测在同样十轮下能把准确率从 0.966 抬到 0.975 上下而且损失曲线明显更平滑。注意加了动量之后等效步长变大了学习率可以适当降一点比如从 0.1 降到 0.05否则前期会震荡。学习率衰减是另一个几乎零成本的改进训练后期把学习率调小让参数在谷底精细收敛而不是一直在附近跳。最简单的实现是按轮数线性衰减lr_t lr0 / (1.0 0.2 * epoch)十轮下来学习率会降到初始值的约三分之一。这一改动能再涨零点几个点主要收益是最后几轮的损失不再抖动验证准确率更稳定。提示动量和衰减都要在朴素版本已经能稳定收敛之后再加。如果基础版本还没跑通就堆这些技巧出问题时你分不清是哪一个引入的。6. 训练卡住时的排查顺序6.1 损失不动或者直接变成 nan损失停在 2.30 附近不动按这个顺序查第一步确认输入做了除以 255 的归一化——这一步漏掉时梯度尺度是正常情况的两百多倍学习率 0.1 会直接把权重推到饱和区表现就是损失从第一轮就卡在基线值。第二步打印第一个 batch 的梯度范数如果全是 0回去看是不是初始化用了全零或者反向传播里某个掩码写反了。第三步单独取十个样本做一次过拟合测试关掉打乱用这十个样本反复训练一百轮如果损失降不到 0.01 以下说明网络结构或者反向传播有硬错误跟数据量无关。损失变成 nan 就简单得多基本都是数值溢出。查三处softmax 有没有减最大值、交叉熵的 log 有没有加极小值、学习率是不是太大导致权重爆炸。还有一个隐蔽的来源是除零——如果某个 batch 的样本数小于预期最后一个不完整的 batch而某处又按固定大小做了除法就会产生 inf。6.2 损失在降但准确率不涨这种情况我遇到过两次。第一次是标签错位数据加载时标签按 1 到 10 编号代码按 0 到 9 索引结果每个样本都在学错一位的类别。损失确实在降因为网络学到了一个固定的偏移映射但准确率永远上不去。检查方法很简单打印前二十个样本的标签和对应的预测人眼一看就知道。第二次是评估函数写错。argmax的轴搞错了在 batch 维度上取最大值返回的全是零准确率恰好卡在 10% 左右。所以准确率异常时先怀疑评估代码再怀疑模型。还有一种不那么错误的情况损失缓慢下降准确率也在缓慢上升但都远低于预期。这通常是容量不够或者训练不够加宽隐层128 到 512、增加轮数10 到 30一般就能解决。6.3 训练集很准但验证集很烂十轮训练之后训练集准确率 99.8%、验证集 95%这个差距说明开始过拟合了。10 万参数对 6 万样本来说容量偏大过拟合是正常现象不用惊讶处理手段有几个加 L2 正则在损失里加上 $\frac{\lambda}{2}\lVert W \rVert^2$梯度里对应加 $\lambda W$$\lambda$ 从 1e-4 试起。这个改动只有两行代码效果直接。早停每轮算一次验证准确率记录最好的那一轮训练结束后用那一轮的参数而不是最后一轮的。减少轮数。这个网络在第 6 到第 8 轮之间验证指标就见顶了再训练只是让训练集更准。我一般会把验证准确率的最高值和对应轮数打印出来而不是只看最后一轮。这个习惯帮我省过很多次明明第 7 轮就到 97.2%最后交上去的是第 15 轮的 96.1%的冤枉。7. 从 90% 到 97%这份代码还能榨出的那点精度先把基线摆清楚不归一化、全零初始化、学习率 0.1大概能到 90% 出头。做到 96% 靠的是归一化加 He 初始化这两件事它们的收益远大于调参。再往上每零点几个点的提升都开始变贵。从 96.6% 到 97.5% 这一段我试过的改动按性价比排序改动代码量准确率变化备注加动量0.94 行0.9 个点最划算学习率衰减1 行0.3 个点后几轮更稳隐层加到 256改一个数0.5 个点训练变慢约一倍加 L2 正则2 行0.2 个点主要压过拟合换成 LeakyReLU2 行0.1 个点收益不明显再往上就基本到这份结构的上限了。全连接网络丢掉了像素之间的空间关系784 维输入被拉平成一维相邻像素的结构信息在第一步就损失掉了。想突破 98%换能利用局部结构的网络结构才是正路那是后面章节的事。我在这一章想留下来的东西其实很简单一个 2.302 的基线、一个形状检查的习惯、一张学习率和批大小的对照表以及先跑通朴素版本再加技巧这个顺序。这四样东西在后面所有模型上都能直接复用比记住某一行公式有用得多。真正容易翻车的从来不是推导而是那个忘了除的 255和那个写反的转置。