📗 第1章 绪论
📄 一句话概括

一个 2 输入、3 隐单元、1 输出的网络,总共只有 13 个参数。 输入 (0.7, -1.2),前向算到输出 0.557000,目标是 1.0,误差 -0.443000。 反向传回去,隐层三个梯度是 -0.886、0、-0.2658,中间那个是零——因为它的 relu 输出恰好是零, 梯度从那儿过不去。 三个参数逐个做数值校验,解析梯度和数值梯度到小数点后八位完全一致。

📝 本节和前面几节的关系

前几节说了「层」「特征」「表示」,都是概念。 这一节把最小的一个网络完全拆开,把每一行算式都算给你看。 为什么要费这个劲?因为深度学习的全部机制就三件事:前向、反向、更新。 再大的网络——几百层、上亿参数——做的还是这三件事,只是重复得更多。 把小的搞透,大的才不神秘。 这一节也是全书唯一一次把数字逐个印出来对账。 后面讲卷积、讲玻尔兹曼机、讲自编码器,都建立在这一节的机制上。

一、网络的结构和它的参数

形状是这样的:

层 形状 参数个数
输入 2 维 0
第 1 层权重 W1 2 乘 3 6
第 1 层偏置 b1 3 个 3
第 2 层权重 W2 3 乘 1 3
第 2 层偏置 b2 1 个 1
加起来 13 个参数。这个规模小到可以在纸上算完。
权重和偏置都写死在代码里,不训练——因为这一节要看的不是「训练出来什么」,
而是「一步计算长什么样」。

二、前向:一层层往前算

输入 x 等于 (0.7, -1.2),目标值 1.0。 第 1 层先做线性变换 z1 等于 W1 转置乘 x 加 b1:

单元 算式 结果
第 1 个 0.5 乘 0.7 + 0.2 乘 负 1.2 + 0.1 +0.210000
第 2 个 负 0.3 乘 0.7 + 0.9 乘 负 1.2 减 0.2 负 1.490000
第 3 个 0.8 乘 0.7 减 0.4 乘 负 1.2 + 0.05 +1.090000
然后过 relu,负数变成零:
a1 = (0.210000, 0.000000, 1.090000)

注意中间那个 0。它现在看起来不起眼,一会儿会成为整节最关键的一处。 第 2 层:z2 等于 W2 转置乘 a1 加 b2,逐项累加得到 0.230000、0.230000、0.557000。 输出 0.557000,目标 1.0,误差负 0.443000。

三、反向:把误差一层层变回梯度

损失用平方误差 L 等于 (y 减 t) 的平方。 它对输出的导数是 2 乘 (0.557000 减 1.0),等于负 0.886000。 输出层的权重梯度等于隐层激活乘以这个导数:

W2 的梯度 = a1 * (-0.886000) = (-0.186060, 0.000000, -0.965740)

第二个是零,因为 a1 的第二个分量是零。 一个从没被激活过的单元,它对输出的影响是零,所以它的权重梯度也是零——这一步更新不会动它。 再往隐层传:先乘 W2 的系数,再乘 relu 的导数。

隐单元 乘 W2 系数 乘 relu 导数 隐层梯度
第 1 个 负 0.886 乘 1.0 乘 1 负 0.886000
第 2 个 负 0.886 乘 负 0.6 乘 0 0.000000
第 3 个 负 0.886 乘 0.3 乘 1 负 0.265800
第 2 个单元那两列值得对比:乘 W2 系数之后是正 0.5316,再乘 relu 导数(这里是 0)就变成 0。
这就是「死单元」的由来:一旦某个单元的输入恒为负,relu 恒输出零,它的梯度永远是零,
参数再也不会更新,这个单元就废了。
这是 relu 的一个真实缺陷,第 6 章讲激活函数时会给出几种补救办法。

四、梯度对不对,验一下

解析梯度算出来了,但它对不对?最硬的验证是数值微分:

数值梯度 = ( L(参数加 eps) - L(参数减 eps) ) / ( 2 * eps )

实测三个参数:

参数 解析梯度 数值梯度
W1 的 (0,0) 负 0.62020000 负 0.62020000
W1 的 (1,2) +0.31896000 +0.31896000
W2 的 (2,0) 负 0.96574000 负 0.96574000
到小数点后八位完全一致。
这个检查在自己实现反向传播时是必须做的——反向传播的链式法则很容易写错一个转置、少乘一个导数,
而错了之后训练仍然会「跑起来」,只是学得慢或者学不对。
拿到一个训不动的网络,第一步就该做这个检查,而不是去调学习率。

五、三件事,重复几万次

到这里机制就齐了:

前向:把输入一层层变成输出
反向:把误差一层层变回梯度(链式法则,没有别的魔法)
更新:每个参数沿自己的梯度反方向挪一小步

再大的网络也是这三件事。 卷积网络多了「权重共享」的结构约束,循环网络多了「时间上的展开」,但前向、反向、 更新这三步一步都没变。 所谓的「深度学习框架」,替人做的就是这三步的自动化:自动求导、自动分配到 GPU、自动管理显存。 框架让这件事变快了,但没有让它变得更神秘。

六、动手 Demo:什么是深度学习:一个 13 个参数的网络,逐层算给你看

这段代码把权重全部写死,然后一层层打印前向的每一步、反向的每一个梯度, 最后用数值微分把三个梯度逐个校验一遍。 重点看三处。第二段第 2 个隐单元的输出 0.000000;第三段隐层梯度中间那个 0; 第四段三行解析梯度与数值梯度完全一致。

# -*- coding: utf-8 -*-
import numpy as np

# 一个 2-3-1 的网络:两个输入、三个隐单元、一个输出
# 权重和偏置都写死,方便一行行对账
W1 = np.array([[0.5, -0.3, 0.8],
               [0.2, 0.9, -0.4]])
b1 = np.array([0.1, -0.2, 0.05])
W2 = np.array([[1.0], [-0.6], [0.3]])
b2 = np.array([0.02])

x = np.array([0.7, -1.2])
target = 1.0


def relu(z):
    return np.maximum(z, 0.0)


def forward(x):
    z1 = W1.T @ x + b1
    a1 = relu(z1)
    z2 = W2.T @ a1 + b2
    return z1, a1, z2


print('[1] 一个只有 13 个参数的小网络')
print('  输入 x = (%.1f, %.1f),目标值 %.1f' % (x[0], x[1], target))
print('  W1 是 2 乘 3,b1 是 3 个,W2 是 3 乘 1,b2 是 1 个')
print('  参数总数 %d' % (W1.size + b1.size + W2.size + b2.size))
print('')

z1, a1, z2 = forward(x)
print('[2] 前向传播:一层一层往前算')
print('  第 1 层线性部分 z1 = W1 转置乘 x 加 b1')
for j in range(3):
    print('    z1[%d] = %.3f * %.1f + %.3f * %.1f + %.3f = %+.6f'
          % (j, W1[0, j], x[0], W1[1, j], x[1], b1[j], z1[j]))
print('  第 1 层激活 a1 = relu(z1)')
for j in range(3):
    print('    a1[%d] = max(0, %+.6f) = %.6f' % (j, z1[j], a1[j]))
print('  第 2 层线性部分 z2 = W2 转置乘 a1 加 b2')
acc = b2[0]
for j in range(3):
    acc += W2[j, 0] * a1[j]
    print('    %+.6f * %.6f -> 累计 %.6f' % (W2[j, 0], a1[j], acc))
print('  输出 %.6f,目标 %.1f,误差 %.6f' % (z2[0], target, z2[0] - target))
print('')

print('[3] 损失对输出的导数,然后一层层往回传')
d2 = 2.0 * (z2[0] - target)     # 平方损失对输出的导数
print('  平方损失 L = (y - t) 的平方,对输出的导数是 2 乘 (%.6f - %.1f) = %.6f'
      % (z2[0], target, d2))
gW2 = a1.reshape(3, 1) * d2
gb2 = np.array([d2])
print('  输出层权重梯度 W2 的梯度 = a1 乘这个导数:', np.round(gW2.ravel(), 6))
d1 = (W2.ravel() * d2) * (z1 > 0.0)
print('  传到隐层:先乘 W2 再乘 relu 的导数(正数处为 1,负数处为 0)')
print('    W2 的系数 ', np.round(W2.ravel(), 3))
print('    relu 导数 ', (z1 > 0.0).astype(int))
print('    隐层梯度 d1 =', np.round(d1, 6))
gW1 = np.outer(x, d1)
gb1 = d1
print('  隐层权重梯度 W1 的梯度 = 输入 x 乘 d1:')
print('   ', np.round(gW1, 6))
print('')

print('[4] 验一下这个梯度对不对')
step = 1e-5
print('  参数         解析梯度      数值梯度')
for name, arr, garr in (('W1[0,0]', W1, gW1), ('W1[1,2]', W1, gW1), ('W2[2,0]', W2, gW2)):
    i, j = int(name[3]), int(name[5])
    old = arr[i, j]
    arr[i, j] = old + step
    lp = (forward(x)[2][0] - target) ** 2
    arr[i, j] = old - step
    lm = (forward(x)[2][0] - target) ** 2
    arr[i, j] = old
    print('  %-10s  %+.8f   %+.8f' % (name, garr[i, j], (lp - lm) / (2 * step)))

print('')
print('[5] 这个循环就是深度学习在做的事')
print('  前向:把输入一层层变成输出')
print('  反向:把误差一层层变回梯度(用的是链式法则,没有别的魔法)')
print('  更新:每个参数沿自己的梯度反方向挪一小步')
print('  重复几万次,那些「特征」就在隐层里自己长出来了')

实跑输出:

--------------------------------------------------------------------
什么是深度学习:一个 13 个参数的网络,逐层算给你看
--------------------------------------------------------------------
[1] 一个只有 13 个参数的小网络
  输入 x = (0.7, -1.2),目标值 1.0
  W1 是 2 乘 3,b1 是 3 个,W2 是 3 乘 1,b2 是 1 个
  参数总数 13

[2] 前向传播:一层一层往前算
  第 1 层线性部分 z1 = W1 转置乘 x 加 b1
    z1[0] = 0.500 * 0.7 + 0.200 * -1.2 + 0.100 = +0.210000
    z1[1] = -0.300 * 0.7 + 0.900 * -1.2 + -0.200 = -1.490000
    z1[2] = 0.800 * 0.7 + -0.400 * -1.2 + 0.050 = +1.090000
  第 1 层激活 a1 = relu(z1)
    a1[0] = max(0, +0.210000) = 0.210000
    a1[1] = max(0, -1.490000) = 0.000000
    a1[2] = max(0, +1.090000) = 1.090000
  第 2 层线性部分 z2 = W2 转置乘 a1 加 b2
    +1.000000 * 0.210000 -> 累计 0.230000
    -0.600000 * 0.000000 -> 累计 0.230000
    +0.300000 * 1.090000 -> 累计 0.557000
  输出 0.557000,目标 1.0,误差 -0.443000

[3] 损失对输出的导数,然后一层层往回传
  平方损失 L = (y - t) 的平方,对输出的导数是 2 乘 (0.557000 - 1.0) = -0.886000
  输出层权重梯度 W2 的梯度 = a1 乘这个导数: [-0.18606 -0.      -0.96574]
  传到隐层:先乘 W2 再乘 relu 的导数(正数处为 1,负数处为 0)
    W2 的系数  [ 1.  -0.6  0.3]
    relu 导数  [1 0 1]
    隐层梯度 d1 = [-0.886   0.     -0.2658]
  隐层权重梯度 W1 的梯度 = 输入 x 乘 d1:
    [[-0.6202   0.      -0.18606]
 [ 1.0632  -0.       0.31896]]

[4] 验一下这个梯度对不对
  参数         解析梯度      数值梯度
  W1[0,0]     -0.62020000   -0.62020000
  W1[1,2]     +0.31896000   +0.31896000
  W2[2,0]     -0.96574000   -0.96574000

[5] 这个循环就是深度学习在做的事
  前向:把输入一层层变成输出
  反向:把误差一层层变回梯度(用的是链式法则,没有别的魔法)
  更新:每个参数沿自己的梯度反方向挪一小步
  重复几万次,那些「特征」就在隐层里自己长出来了

第二段那三行 z1 的算式要真跟着算一遍。 0.5 乘 0.7 是 0.35,0.2 乘 负 1.2 是 负 0.24,加 0.1 得 0.21。 手算一遍和只看结果,理解程度差很多。 第二段第 2 个单元输出 0.000000,当时看不出什么。 可它在第三步会引发连锁:梯度为零、权重不更新、这个单元永远不再参与。 这就是 relu 的「死单元」问题,本节是它最干净的一次现场。 第三段那张表的第 2 行是这篇的题眼。 乘 W2 系数之后它是正 0.5316,看着还挺有劲;再乘 relu 导数 0,立刻归零。 反向传播里「乘一个零」就等于切断这条路,这是所有「学不动」问题的共同形态。 第四段三个参数都是写死的,所以可以反复改、反复算。 实际训练时这些参数每步都在变, 但校验方法完全一样——在任何一个训练好的模型上都可以随手抽一个参数做这个检查。 代码里 forward 返回了 (z1, a1, z2) 三个中间量。 反向时要用到 z1(判断 relu 是否激活)和 a1(算权重梯度),所以前向必须把这些存下来。 这也是深度学习吃显存的主要原因:中间结果要留着给反向用。

30 秒实验

  1. 把 W1 的第一行第一个数从 0.5 改成 负 0.5,重跑。 z1 的第 1 个会变成负数,a1 的第 1 个变成 0,于是隐层梯度又多一个零。 两个单元都死掉之后,输出层几乎学不动——这就是「死得太多的网络救不回来」。
  2. 把 relu 换成 np.tanh,并把反向里的 (z1 > 0.0) 换成 1 - a1 * a1,重跑。 不会再出现梯度为零的单元(tanh 的导数处处非零)。 代价是层一多,梯度会一层层缩小——这就是「梯度消失」。
  3. 把 step 从 1e-5 改成 1e-2,重跑第四段。解析梯度和数值梯度会对不上。 数值微分本身有精度要求:eps 太大截断误差大,太小浮点误差大,一般在 1e-5 附近。

常见误区

  1. 「反向传播是一种优化算法」。 它不是优化算法,它只是「算梯度」的方法。 优化是梯度下降在做的事,反向传播只负责把梯度算出来。 两者经常被混为一谈。
  2. 「网络训不动,先调学习率」。 先做梯度校验。 本节那三个参数的对账只要几十行代码,几秒钟就跑完。 如果反向传播写错了,调学习率只是在错误的梯度上换步长。
  3. 「relu 很简单,就是取最大值,没什么副作用」。 它有死单元问题:本节第 2 个隐单元输出恰为零,梯度也恰为零,永远不再更新。 层数一多、学习率一大,死掉的单元能占很大比例。
  4. 「参数多所以模型神秘」。 机制一点都不神秘:13 个参数和 1 亿个参数,前向反向的式子一模一样。 神秘的是「学到的中间表示对应什么含义」,那是解释性的问题,不是机制的问题。
自测三问(点开对答案) 1. 为什么隐层第 2 个单元的梯度是零? —— 因为它的 z1 是负 1.49,过 relu 之后输出 0。 反向传播要乘 relu 的导数,而 relu 在负数处的导数是 0,一乘就归零了。 这意味着这个单元的参数永远不会更新,也就是「死单元」。 2. 为什么要做数值梯度校验? —— 因为反向传播的链式法则很容易写错一个转置或少乘一个导数,而写错之后训练仍然会跑起来, 只是学得慢或学不对,很难从现象上发现。 数值微分用有限差分独立算一遍梯度,两者对上才说明推导没错。 3. 前向传播时为什么要保存中间结果? —— 因为反向传播要用:算权重梯度需要那层的输入激活,算传回去的误差需要 relu 的输入符号。 不保存就得重算一遍前向。这也是深度学习吃显存的主要原因。

机制讲完了,接下来该讲「这套机制在图像上行不行」。 把 8 乘 8 的图直接展平成 64 维喂给全连接层,参数量会随图像尺寸平方增长, 而且平移一点点就全乱了。 第 3 章换一套结构来解决这件事。

相关笔记