梯度和初始化
这一章要解决深度学习里的两个"卡脖子"问题。第一个:一个动辄上亿参数的神经网络,怎么高效算出每个参数该往哪调?答案叫反向传播(Backpropagation)——把误差从输出端"一层层往回传"。第二个:训练开始前参数从哪出发?答案与参数初始化有关,初始化得不好,训练可能根本跑不动。学完这一章,你会搞懂梯度为什么会"消失"或"爆炸",也会知道为什么不能把所有权重都设成 0。
反向传播:误差的"接力传话"
回顾一下训练流程:把数据送进网络,算出预测和损失,这一步叫前向传播(Forward Propagation);然后梯度下降要靠"损失对每个参数的导数"来更新参数。问题是,一个网络有上亿个参数,难道要为每个参数单独算一遍导数吗?那计算量谁都扛不住。
好消息是,这些导数之间藏着大量的重复:损失对"靠前层"参数的导数,一定会用到损失对"靠后层"的导数。只要先把后面的算好,前面的就能直接复用。于是就有了反向传播——先做一次前向传播,把每一层的中间结果(激活值)记下来;再从输出端开始,从后往前一层层算梯度,算完一层就把结果传给更前面的一层。
想象一列人从终点往起点"传话":最后一棒从裁判那里拿到"误差"这条消息,他加上自己知道的局部信息(自己这一棒的导数),传给前一棒;前一棒同样加上自己的局部信息再往前传。每个人只做一点点计算,消息却能一路传到起点——反向传播就是这个过程:每个位置只算自己的"局部导数",再把消息乘上去往前传。
注意方向:前向传播是"数据往里走",反向传播是"误差往回走",方向正好相反;前向时记下的每一层激活值,就是反向时要用到的"局部信息"。
链式法则:把连锁反应拆开算
反向传播的数学内核只有一个:链式法则(Chain Rule)。它说的是:一个变化如果经过了好几个环节才传到损失,那么"损失对源头变化的敏感度",等于一路上每一环的敏感度乘起来。
用三层网络举例。输入 $x$ 依次变成预激活 $f_0$、激活 $h_1$、预激活 $f_2$、激活 $h_3$、输出 $f_3$,最后算出损失 $\ell_i$。想知道"损失对 $f_2$ 的变化有多敏感",可以拆成三段:
$$\frac{\partial \ell_i}{\partial f_2} = \frac{\partial h_3}{\partial f_2} \cdot \frac{\partial f_3}{\partial h_3} \cdot \frac{\partial \ell_i}{\partial f_3}$$这个式子从右往左读:$f_3$ 变一点点,损失变多少;$h_3$ 变一点点,$f_3$ 变多少;$f_2$ 变一点点,$h_3$ 变多少。三段敏感度一乘,就是 $f_2$ 对损失的完整影响。关键在于:右边这些项在算更前面的层时还会再次用到,所以从后往前算,每一项只需算一次——这就是反向传播高效的秘密:它把重复的计算全省掉了。
import numpy as np
# 一个极小的网络:pred = w2 * relu(w1 * x),损失用平方误差
x, y_true = 1.5, 2.0
w1, w2 = 0.8, 1.2
# 前向传播:记下每个中间值(反向传播时要用)
z1 = w1 * x # 第一层预激活
h = max(z1, 0.0) # ReLU 激活
pred = w2 * h # 网络输出
loss = (pred - y_true) ** 2 # 平方误差损失
# 反向传播:按链式法则从后往前算每个导数
dl_dpred = 2 * (pred - y_true) # 损失对输出的导数
dl_dw2 = dl_dpred * h # 输出对 w2 的导数
dl_dh = dl_dpred * w2 # 输出对 h 的导数
dl_dz1 = dl_dh * (1.0 if z1 > 0 else 0.0) # ReLU 的导数:输入为正时为 1
dl_dw1 = dl_dz1 * x # z1 对 w1 的导数
print(f"dw1 = {dl_dw1:.3f}, dw2 = {dl_dw2:.3f}, loss = {loss:.3f}")
跑一下这段代码,你会亲眼看到两个梯度是怎么被一步步乘出来的。在 PyTorch 或 TensorFlow 里,这一切通常只是一行 loss.backward(),但底层跑的就是这套逻辑:前向记录中间值,反向逐层复用。
梯度消失与梯度爆炸
链式法则能高效算梯度,但也埋下一个隐患:越是靠前的层,它的梯度越是后面每一层导数乘在一起的结果。以第 0 层的权重 $\omega_0$ 为例:
$$\frac{\partial \ell_i}{\partial \omega_0} = \frac{\partial \ell_i}{\partial f_K} \cdot \prod_{k=1}^{K} \left( \frac{\partial f_k}{\partial h_k} \cdot \frac{\partial h_k}{\partial f_{k-1}} \right) \cdot \frac{\partial f_0}{\partial \omega_0}$$这个式子读作:损失对 $\omega_0$ 的敏感度,等于一路上每一个环节的敏感度全部乘起来,网络层数 $K$ 越大,乘的因子越多。假如每一层的因子平均是 0.5,50 层之后:
$$0.5^{50} \approx 8.9 \times 10^{-16}$$这个数小到几乎等于 0——靠前的层收不到任何梯度,参数永远不动,网络只有最后几层在"孤军奋战"。这就是梯度消失(Vanishing Gradients)。反过来,如果每一层的因子平均是 1.5:
$$1.5^{50} \approx 6.4 \times 10^{8}$$梯度暴涨,参数一步迈出十万八千里,训练直接震荡甚至发散。这就是梯度爆炸(Exploding Gradients)。
这就像"传话游戏":如果每个人传话时都把音量打 9 折,传 50 个人之后最后一棒什么都听不见(梯度消失);如果每个人传话时都把音量放大 1.5 倍,传 50 个人之后最后一棒耳朵都要被震聋(梯度爆炸)。信息要么死在半路,要么炸在半路。
参数初始化:从哪儿出发很重要
既然梯度是"逐层相乘"的,那自然的想法是:能不能让每一层乘出来的效果刚好是 1,让信号既不放大也不缩小?这就引出了参数初始化——训练开始前,权重从哪个随机状态出发。
先看一个经典的反面教材:如果把所有权重和偏置都初始化为 0,会发生什么?同一层的所有神经元会变得完全一样——它们接收相同的输入、算出相同的值、收到相同的梯度,于是无论训练多久都保持相同,整个网络退化成"每层只有一个神经元在工作",表达能力大打折扣。所以初始化必须打破对称性,让神经元从不同的起点出发。
怎么做到"等幅通过"?一个神经元要累加很多个输入(假设有 $D_h$ 个),如果每个权重都太大,求和就会爆表,所以权重要小,而且要随输入个数调整:输入越多,单个权重就得越小。具体做法是:偏置初始化为 0,权重按均值为 0、方差为 $\sigma^2$ 的正态分布随机取值,其中
$$\sigma^2 = \frac{2}{D_h}$$$D_h$ 是这一层输入(源层)的宽度。这个式子特意考虑到了 ReLU 会砍掉约一半的信号,所以把方差放大一倍来补偿——这就是著名的He 初始化。再早一点提出的Xavier 初始化思路类似,只是没有专门针对 ReLU 修正;当相邻两层宽度不同时,可以取两者的折中:
$$\sigma^2 = \frac{4}{D_h + D_{h'}}$$实际写代码时你多半不用自己算——PyTorch 的 nn.init.kaiming_normal_() 就是 He 初始化,nn.init.xavier_normal_() 就是 Xavier 初始化。但理解了背后的直觉(打破对称、等幅通过),你才知道这些初始化为什么是"正确"的起点。