反向传播不是优化算法,它只负责算梯度;优化是梯度下降在做的事。 39 个参数的三层网络,抽 6 个权重做对账:解析梯度与数值梯度的相对误差最大 7.23e-08。 故意漏乘隐层的激活导数之后,同一个权重的梯度差 1566 倍,符号都能反掉。 而程序不会报错、损失还是会慢慢降——所以这种 bug 只能靠梯度校验抓。
上一节用 9 个参数手工解了异或,但那组数是人想的。 要让机器自己找,得回答一个问题:隐层的权重错在哪? 输出层好办,它有标签;隐层没有标签,谁也不知道它「应该」输出什么。 反向传播的回答是:不用知道它应该输出什么,只要知道「它对最终误差负多大责任」。 这个责任可以用链式法则一层层算回去。 这一节分三块:把链式法则写好、用数值微分当独立裁判来对账、再故意写错一次看能错多远。
一、链式法则,从输出往回
网络只有三步:前向算输出、算损失、反向算梯度。 反向那一步,是把损失对每个参数的偏导,按链式法则拆成一段段乘起来:
dL/dW1 = dL/dz3 * dz3/dh2 * dh2/dz2 * dz2/dh1 * dh1/dz1 * dz1/dW1
其中每一段 dz/dh 是下一层的权重,
每一段 dh/dz 是激活函数的导数。
实际实现时不会真的写这么长一串,而是从后往前递推:
d = softmax(输出) - 目标 # 输出层的误差
for 每一层,从后往前:
该层权重梯度 = 上一层的输出.T @ d
该层偏置梯度 = d 按行求和
d = (d @ 该层权重.T) * 激活导数 # 传给前一层
这个递推里只有两个动作:乘权重、乘激活导数。 所以「反向传播」这个名字很准确——它就是把这个 d 一层层往回传。
二、用它算出来的梯度,对不对
对账的办法是数值微分(中心差分):
把某个权重加一点点 eps,算一次损失 -> L+
把同一个权重减一点点 eps,算一次损失 -> L-
数值梯度 = (L+ - L-) / (2 * eps)
它只用损失函数,和反向传播的代码完全无关,所以能当独立裁判。 两边算出来的数一样,才说明反向传播写对了。 实测(3 维输入、隐层 4 和 3、2 类输出,39 个参数,抽 6 个):
| 层 | 位置 | 解析梯度 | 数值梯度 | 相对误差 |
|---|---|---|---|---|
| W1 | [0,0] | 0.0003837756 | 0.0003837756 | 7.23e-08 |
| W1 | [1,2] | 0.0070870410 | 0.0070870409 | 4.75e-09 |
| W2 | [0,0] | 0.0054176050 | 0.0054176050 | 5.10e-09 |
| W2 | [1,2] | 0.0282861042 | 0.0282861042 | 1.13e-10 |
| W3 | [0,0] | 0.3352159454 | 0.3352159454 | 3.72e-11 |
| W3 | [1,1] | 0.1559833691 | 0.1559833691 | 7.82e-11 |
| 六个数全对到小数点后八位。 | ||||
| 误差在 1e-08 到 1e-11 之间,这个量级是差分本身的截断误差,不是写错了。 |
三、故意漏乘一次,会差多少
最容易漏的就是隐层那个激活导数。 很多人第一版反向传播只写了「乘权重」,忘了再乘一层 tanh 的导数。 程序不报错,损失还是会降,只是降得不对。 实测把这一步去掉:
| 层 | 位置 | 正确梯度 | 漏乘之后 | 相差倍数 |
|---|---|---|---|---|
| W1 | [0,0] | 0.00038378 | -0.60114699 | 1566.402 倍 |
| W1 | [0,1] | -0.19538811 | -2.27585300 | 11.648 倍 |
| W1 | [1,0] | -0.01635163 | 0.40550440 | 24.799 倍 |
| W2 | [0,0] | 0.00541760 | 0.00241694 | 0.446 倍 |
| W2 | [0,1] | 0.00042421 | -0.01578307 | 37.206 倍 |
| W2 | [1,0] | -0.00715106 | -0.03610241 | 5.049 倍 |
| 注意 W1 那一行的符号:正确的梯度是正的,漏乘之后变成负的。 | ||||
| 差的不只是大小,是方向。 | ||||
| W2 那一行只差 0.446 倍,看上去还行——这说明这种 bug 有时反而更危险, | ||||
| 因为结果「看起来差不多」,没人会去查。 | ||||
| 这就是为什么梯度校验必须做,而且要在小网络上做。 | ||||
| 几十行代码、几秒钟跑完,能省掉后面几天的调试。 |
四、三个容易混的点
第一,反向传播不是优化算法。 它只算梯度,怎么用这个梯度是梯度下降的事。 两者经常被混为一谈,但分开看才能理解为什么换个优化器(Adam、动量)不影响反向传播的任何一行。 第二,梯度从上往下是「逐层变小的」。 因为每一层都要乘激活导数,而 tanh 的导数最大是 1、sigmoid 最大是 0.25。 层数越多,前面的层拿到梯度越小——这就是梯度消失。 ReLU 的导数是 1,所以它缓解了这个问题,这也是它在 2011 年之后流行的原因。 第三,中间结果必须留着。 反向时要用到每层的输入和激活值,所以前向不能算完就扔。 这是深度学习吃显存的主要原因:不是参数多,是中间结果多。
五、动手 Demo:逐层梯度对账,再故意写错一次
这段代码搭一个 3 到 4 到 3 到 2 的三层网络,39 个参数,抽 6 个权重分别用反向传播和中心差分算梯度, 逐个对账;再把隐层的激活导数去掉,看梯度差多少。 重点看三处。第二段六行的相对误差都在 1e-08 以下;第三段 W1 那一行从 0.00038 变成 -0.60115; 以及 W2 那一行只差 0.446 倍这个反例。
# -*- coding: utf-8 -*-
import unicodedata
import numpy as np
def wd(s):
return sum(2 if unicodedata.east_asian_width(c) in ('W', 'F') else 1 for c in s)
def pad(s, n):
return s + ' ' * max(0, n - wd(s))
rg = np.random.default_rng(7)
n, d, C = 6, 3, 2
X = rg.normal(0.0, 2.5, (n, d))
T = np.zeros((n, C))
T[np.arange(n), rg.integers(0, C, n)] = 1.0
SIZES = [d, 4, 3, C]
def init(seed=0):
r = np.random.default_rng(seed)
W, b = [], []
for i in range(len(SIZES) - 1):
W.append(r.normal(0.0, 2.0, (SIZES[i], SIZES[i + 1])))
b.append(r.normal(0.0, 0.1, SIZES[i + 1]))
return {'W': W, 'b': b}
def forward(p, X):
Z, A = [], [X]
h = X
for i in range(len(p['W'])):
z = h @ p['W'][i] + p['b'][i]
Z.append(z)
h = z if i == len(p['W']) - 1 else np.tanh(z)
A.append(h)
return A, Z
def softmax(S):
S = S - S.max(axis=1, keepdims=True)
E = np.exp(S)
return E / E.sum(axis=1, keepdims=True)
def loss(p, X, T):
A, _ = forward(p, X)
P = softmax(A[-1])
return float(-np.mean(np.log(np.maximum((P * T).sum(axis=1), 1e-12))))
def backward(p, X, T, drop_relu=False):
"""三层网络的完整反向传播。drop_relu=True 时故意漏乘隐层的激活导数。"""
A, Z = forward(p, X)
L = len(p['W'])
d = (softmax(A[-1]) - T) / len(X)
gW = [None] * L
gb = [None] * L
for i in range(L - 1, -1, -1):
gW[i] = A[i].T @ d
gb[i] = d.sum(axis=0)
if i > 0:
dh = d @ p['W'][i].T
if not drop_relu:
dh = dh * (1.0 - A[i] * A[i])
d = dh
return {'W': gW, 'b': gb}
def num_grad(p, X, T, which, i, j, eps=1e-6):
"""中心差分:只动一个数,前后各算一次损失。"""
old = p[which][i][j]
p[which][i][j] = old + eps
lp = loss(p, X, T)
p[which][i][j] = old - eps
lm = loss(p, X, T)
p[which][i][j] = old
return (lp - lm) / (2.0 * eps)
p = init(0)
g = backward(p, X, T)
print('[1] 网络结构:%d 维输入 -> 隐层 4 -> 隐层 3 -> %d 类输出' % (d, C))
NPAR = sum(w.size for w in p['W']) + sum(v.size for v in p['b'])
print(' 参数个数 = %d(权重 %d 个 + 偏置 %d 个),损失取交叉熵,隐层用 tanh'
% (NPAR, sum(w.size for w in p['W']), sum(v.size for v in p['b'])))
print('')
print('[2] 逐个参数对账:解析梯度(反向传播算的)vs 数值梯度(差分算的)')
print(' 层 位置 解析梯度 数值梯度 相对误差')
tot = 0
bad = 0
for li in range(len(p['W'])):
for (a, b_) in ((0, 0), (1, 2) if p['W'][li].shape[1] > 2 else (1, 1)):
ana = g['W'][li][a, b_]
num = num_grad(p, X, T, 'W', li, (a, b_))
rel = abs(ana - num) / max(1e-12, abs(ana) + abs(num))
tot += 1
bad += rel > 1e-6
print(' ' + pad('W%d' % (li + 1), 13) + pad('[%d,%d]' % (a, b_), 12)
+ pad('%.10f' % ana, 15) + pad('%.10f' % num, 15)
+ '%.2e' % rel)
print('')
print(' %d 个抽样参数,相对误差超过 1e-6 的有 %d 个' % (tot, bad))
print(' 这一点很重要:如果反向传播写错了,第 [2] 段就会整片对不上')
print('')
print('[3] 故意漏乘隐层的激活导数,看能错多远')
g2 = backward(p, X, T, drop_relu=True)
print(' 层 位置 正确梯度 漏乘之后 相差倍数')
for li in range(len(p['W']) - 1):
for (a, b_) in ((0, 0), (0, 1), (1, 0)):
ok_v = g['W'][li][a, b_]
wr_v = g2['W'][li][a, b_]
k = abs(wr_v) / max(1e-12, abs(ok_v))
print(' ' + pad('W%d' % (li + 1), 13) + pad('[%d,%d]' % (a, b_), 12)
+ pad('%.8f' % ok_v, 15) + pad('%.8f' % wr_v, 15)
+ '%.3f 倍' % k)
print('')
print('[4] 三个必须记住的点')
print(' 漏乘一次激活导数,梯度会差几十倍到上千倍,符号都可能反掉')
print(' 但程序不会报错、损失也会慢慢降——所以这种 bug 只能靠梯度校验抓')
print(' 数值差分只用损失函数,跟反向传播的代码完全无关,所以它能当独立裁判')
实跑输出:
--------------------------------------------------------------------
误差反向传播:逐层梯度对账,再故意写错一次
--------------------------------------------------------------------
[1] 网络结构:3 维输入 -> 隐层 4 -> 隐层 3 -> 2 类输出
参数个数 = 39(权重 30 个 + 偏置 9 个),损失取交叉熵,隐层用 tanh
[2] 逐个参数对账:解析梯度(反向传播算的)vs 数值梯度(差分算的)
层 位置 解析梯度 数值梯度 相对误差
W1 [0,0] 0.0003837756 0.0003837756 7.23e-08
W1 [1,2] 0.0070870410 0.0070870409 4.75e-09
W2 [0,0] 0.0054176050 0.0054176050 5.10e-09
W2 [1,2] 0.0282861042 0.0282861042 1.13e-10
W3 [0,0] 0.3352159454 0.3352159454 3.72e-11
W3 [1,1] 0.1559833691 0.1559833691 7.82e-11
6 个抽样参数,相对误差超过 1e-6 的有 0 个
这一点很重要:如果反向传播写错了,第 [2] 段就会整片对不上
[3] 故意漏乘隐层的激活导数,看能错多远
层 位置 正确梯度 漏乘之后 相差倍数
W1 [0,0] 0.00038378 -0.60114699 1566.402 倍
W1 [0,1] -0.19538811 -2.27585300 11.648 倍
W1 [1,0] -0.01635163 0.40550440 24.799 倍
W2 [0,0] 0.00541760 0.00241694 0.446 倍
W2 [0,1] 0.00042421 -0.01578307 37.206 倍
W2 [1,0] -0.00715106 -0.03610241 5.049 倍
[4] 三个必须记住的点
漏乘一次激活导数,梯度会差几十倍到上千倍,符号都可能反掉
但程序不会报错、损失也会慢慢降——所以这种 bug 只能靠梯度校验抓
数值差分只用损失函数,跟反向传播的代码完全无关,所以它能当独立裁判
这个网络只有 6 个样本,是故意的。
样本少、参数少,数值微分的每一步才看得清,出错也容易定位。
真实模型的梯度校验只能抽几个参数做,原理完全一样。
第二段那个 1e-06 的阈值是经验值。
相对误差在 1e-08 附近是正常的截断误差;到了 1e-04 就该怀疑了;到了 1e-02 一定是写错了。
第三段 W1 的 1566 倍不是笔误,是「梯度几乎为零」被放大出来的结果。
正确的梯度是 0.00038,本来就接近零,任何一点扰动都会让比值爆掉。
所以看相对误差时要同时看绝对大小——这一行的绝对值本身就没多大意义。
W2 那一行 0.446 倍才是真正危险的。
漏乘之后梯度还是同一个量级、同一个符号,训练能跑、损失能降,但走的方向是错的。
这种 bug 不会让程序崩,只会让结果「怎么调都差一点」。
代码里 num_grad 用的是中心差分而不是前向差分,因为中心差分的误差是步长的平方,
而前向差分只有一次方。
同一个 eps,中心差分能多两位有效数字。
30 秒实验
- 把
num_grad的eps从1e-06改成1e-02,重跑。相对误差会明显变大,可能到 1e-04。 eps 太大,截断误差(忽略掉的高阶项)就显出来了。 - 把
eps改成1e-12,重跑。误差同样会变大。 eps 太小,两次损失几乎相等,相减时的浮点误差被放大——这叫舍入误差。 两头都不行,所以常用 1e-05 到 1e-07。 - 把隐层的
np.tanh换成np.maximum(·, 0)(ReLU),重跑第三段。 相差倍数会变小很多,因为 ReLU 的导数要么是 1 要么是 0。 漏乘它等于把「死掉的单元」也当成活的,错得没那么夸张。
常见误区
- 「反向传播是一种优化算法」。 它不是。它只负责算梯度,优化是梯度下降在做的事。 分开看才能理解为什么换优化器不需要改反向传播的任何一行。
- 「梯度校验是新手才做的事」。 恰恰相反,自己写反向传播的时候它是唯一可靠的裁判。 实测漏乘一个导数就能差 1566 倍,而程序不会报错。
- 「梯度小就是学习率小」。 梯度小可能是学习率小,也可能是激活函数饱和、层数太深、或者干脆是反向传播写错了。 先做梯度校验,再调超参数。
- 「前向算完就可以把中间结果扔了」。 反向要用每层的输入和激活值,扔了就得重算。 这是深度学习吃显存的主因:不是参数多,是中间结果多。
自测三问(点开对答案)
1. 反向传播在算什么?它和梯度下降是什么关系? —— 它算的是损失对每个参数的偏导,用链式法则从输出一层层往回递推。 它本身不更新参数,更新是梯度下降做的。所以换优化器(动量、Adam)不影响反向传播的任何一行。 2. 怎么验证自己写的反向传播是对的? —— 用数值微分当独立裁判:把某个权重加减一个很小的 eps,各算一次损失,用差分算梯度。 它只用损失函数,和反向传播的代码无关。 两边相对误差在 1e-08 附近就对了,超过 1e-04 要查,1e-02 一定是错的。 3. 漏乘隐层的激活导数,后果有多严重? —— 实测最多差 1566 倍,符号都能反掉。 但也有只差 0.446 倍的情况,那种更危险——训练能跑、损失能降,只是方向错了, 结果「怎么调都差一点」。这种 bug 只能靠梯度校验抓。梯度算得对了,下一个问题是:损失函数该选什么、激活函数该选什么。 这不是风格问题——选错了,梯度会一路衰减到零,网络根本学不动。 下一节把两种损失、四种激活放在一起比。
- ☐ (本节完,下一节 2.6 误差函数和激活函数)