📗 第2章 神经网络
📄 一句话概括

反向传播不是优化算法,它只负责算梯度;优化是梯度下降在做的事。 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 秒实验

  1. 把 num_grad 的 eps 从 1e-06 改成 1e-02,重跑。相对误差会明显变大,可能到 1e-04。 eps 太大,截断误差(忽略掉的高阶项)就显出来了。
  2. 把 eps 改成 1e-12,重跑。误差同样会变大。 eps 太小,两次损失几乎相等,相减时的浮点误差被放大——这叫舍入误差。 两头都不行,所以常用 1e-05 到 1e-07。
  3. 把隐层的 np.tanh 换成 np.maximum(·, 0)(ReLU),重跑第三段。 相差倍数会变小很多,因为 ReLU 的导数要么是 1 要么是 0。 漏乘它等于把「死掉的单元」也当成活的,错得没那么夸张。

常见误区

  1. 「反向传播是一种优化算法」。 它不是。它只负责算梯度,优化是梯度下降在做的事。 分开看才能理解为什么换优化器不需要改反向传播的任何一行。
  2. 「梯度校验是新手才做的事」。 恰恰相反,自己写反向传播的时候它是唯一可靠的裁判。 实测漏乘一个导数就能差 1566 倍,而程序不会报错。
  3. 「梯度小就是学习率小」。 梯度小可能是学习率小,也可能是激活函数饱和、层数太深、或者干脆是反向传播写错了。 先做梯度校验,再调超参数。
  4. 「前向算完就可以把中间结果扔了」。 反向要用每层的输入和激活值,扔了就得重算。 这是深度学习吃显存的主因:不是参数多,是中间结果多。
自测三问(点开对答案) 1. 反向传播在算什么?它和梯度下降是什么关系? —— 它算的是损失对每个参数的偏导,用链式法则从输出一层层往回递推。 它本身不更新参数,更新是梯度下降做的。所以换优化器(动量、Adam)不影响反向传播的任何一行。 2. 怎么验证自己写的反向传播是对的? —— 用数值微分当独立裁判:把某个权重加减一个很小的 eps,各算一次损失,用差分算梯度。 它只用损失函数,和反向传播的代码无关。 两边相对误差在 1e-08 附近就对了,超过 1e-04 要查,1e-02 一定是错的。 3. 漏乘隐层的激活导数,后果有多严重? —— 实测最多差 1566 倍,符号都能反掉。 但也有只差 0.446 倍的情况,那种更危险——训练能跑、损失能降,只是方向错了, 结果「怎么调都差一点」。这种 bug 只能靠梯度校验抓。

梯度算得对了,下一个问题是:损失函数该选什么、激活函数该选什么。 这不是风格问题——选错了,梯度会一路衰减到零,网络根本学不动。 下一节把两种损失、四种激活放在一起比。

相关笔记