前四章把数据读进来、把形状理顺、把前向计算的几行代码写完,到第五章才算真正碰到训练这件事。很多人在这一关卡住,不是因为公式难,而是因为第一次看到屏幕上那行数字时,完全不知道该拿它怎么办。我印象很深的一次,两层全连接网络第一次跑起来,损失值从 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$ 倍($N=64$ 时就是 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_in=784, n_hidden=128, n_out=10, seed=42): 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, dtype=np.float32) W2 = rng.normal(0, np.sqrt(2.0 / n_hidden), (n_hidden, n_out)).astype(np.float32) b2 = np.zeros(n_out, dtype=np.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(axis=1, keepdims=True) # 数值稳定 e = np.exp(z2) p = e / e.sum(axis=1, keepdims=True) 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(axis=0) # (10,) da1 = dz2 @ W2.T # (N,128) dz1 = da1 * (z1 > 0) # (N,128) dW1 = X.T @ dz1 # (784,128) db1 = dz1.sum(axis=0) # (128,) return [dW1, db1, dW2, db2] def accuracy(X, y, params): p, _ = forward(X, params) return float((p.argmax(axis=1) == y).mean()) def train(X, y, X_val, y_val, epochs=10, batch_size=64, lr=0.1, seed=42): params = init_params(seed=seed) 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:s+batch_size], ys[s:s+batch_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(f"epoch {ep+1:2d} loss {total/n:.4f} val_acc {accuracy(X_val, y_val, params):.4f}") return params调用前记得做归一化,把像素从 0 到 255 压到 0 到 1:
X_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, epochs=10, batch_size=64, lr=0.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.0001 | 2.28 | 0.31 | 几乎不动,十轮白跑 |
| 0.001 | 1.42 | 0.86 | 在降,但明显没跑够 |
| 0.01 | 0.42 | 0.94 | 稳,但偏慢 |
| 0.1 | 0.21 | 0.966 | 这个配置下的甜点区 |
| 0.5 | 0.35 | 0.951 | 能收敛,波动变大 |
| 1.0 | 2.29 | 0.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 翻到 256(4 倍),学习率也相应乘以 4。这条规则在小批量范围内近似成立,批特别大时会失效(超过某个点收益递减),但对这一章 10 万参数的小网络完全够用。
实际调的时候我会固定一个组合去看,而不是孤立地调一个。下面这张表是我认为对这份代码比较稳的几组:
| 批大小 | 学习率 | 单轮耗时(相对) | 十轮后准确率 |
|---|---|---|---|
| 16 | 0.03 | 慢 | 0.962 |
| 64 | 0.1 | 中 | 0.966 |
| 256 | 0.4 | 快 | 0.960 |
| 1024 | 1.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.9) | 4 行 | +0.9 个点 | 最划算 |
| 学习率衰减 | 1 行 | +0.3 个点 | 后几轮更稳 |
| 隐层加到 256 | 改一个数 | +0.5 个点 | 训练变慢约一倍 |
| 加 L2 正则 | 2 行 | +0.2 个点 | 主要压过拟合 |
| 换成 LeakyReLU | 2 行 | +0.1 个点 | 收益不明显 |
再往上就基本到这份结构的上限了。全连接网络丢掉了像素之间的空间关系,784 维输入被拉平成一维,相邻像素的结构信息在第一步就损失掉了。想突破 98%,换能利用局部结构的网络结构才是正路,那是后面章节的事。
我在这一章想留下来的东西其实很简单:一个 2.302 的基线、一个形状检查的习惯、一张学习率和批大小的对照表,以及"先跑通朴素版本再加技巧"这个顺序。这四样东西在后面所有模型上都能直接复用,比记住某一行公式有用得多。真正容易翻车的从来不是推导,而是那个忘了除的 255,和那个写反的转置。