一个 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 秒实验
- 把
W1的第一行第一个数从 0.5 改成 负 0.5,重跑。 z1 的第 1 个会变成负数,a1 的第 1 个变成 0,于是隐层梯度又多一个零。 两个单元都死掉之后,输出层几乎学不动——这就是「死得太多的网络救不回来」。 - 把
relu换成np.tanh,并把反向里的(z1 > 0.0)换成1 - a1 * a1,重跑。 不会再出现梯度为零的单元(tanh 的导数处处非零)。 代价是层一多,梯度会一层层缩小——这就是「梯度消失」。 - 把
step从 1e-5 改成 1e-2,重跑第四段。解析梯度和数值梯度会对不上。 数值微分本身有精度要求:eps 太大截断误差大,太小浮点误差大,一般在 1e-5 附近。
常见误区
- 「反向传播是一种优化算法」。 它不是优化算法,它只是「算梯度」的方法。 优化是梯度下降在做的事,反向传播只负责把梯度算出来。 两者经常被混为一谈。
- 「网络训不动,先调学习率」。 先做梯度校验。 本节那三个参数的对账只要几十行代码,几秒钟就跑完。 如果反向传播写错了,调学习率只是在错误的梯度上换步长。
- 「relu 很简单,就是取最大值,没什么副作用」。 它有死单元问题:本节第 2 个隐单元输出恰为零,梯度也恰为零,永远不再更新。 层数一多、学习率一大,死掉的单元能占很大比例。
- 「参数多所以模型神秘」。 机制一点都不神秘:13 个参数和 1 亿个参数,前向反向的式子一模一样。 神秘的是「学到的中间表示对应什么含义」,那是解释性的问题,不是机制的问题。
自测三问(点开对答案)
1. 为什么隐层第 2 个单元的梯度是零? —— 因为它的 z1 是负 1.49,过 relu 之后输出 0。 反向传播要乘 relu 的导数,而 relu 在负数处的导数是 0,一乘就归零了。 这意味着这个单元的参数永远不会更新,也就是「死单元」。 2. 为什么要做数值梯度校验? —— 因为反向传播的链式法则很容易写错一个转置或少乘一个导数,而写错之后训练仍然会跑起来, 只是学得慢或学不对,很难从现象上发现。 数值微分用有限差分独立算一遍梯度,两者对上才说明推导没错。 3. 前向传播时为什么要保存中间结果? —— 因为反向传播要用:算权重梯度需要那层的输入激活,算传回去的误差需要 relu 的输入符号。 不保存就得重算一遍前向。这也是深度学习吃显存的主要原因。机制讲完了,接下来该讲「这套机制在图像上行不行」。 把 8 乘 8 的图直接展平成 64 维喂给全连接层,参数量会随图像尺寸平方增长, 而且平移一点点就全乱了。 第 3 章换一套结构来解决这件事。
- ☐ (本节完,下一节 1.5 本书结构)