第 6 章 · 学习是怎么发生的

反向传播

第 5 章我们知道了“朝梯度反方向走”就能降低损失。可网络动辄成千上万个参数, 这么多梯度怎么算?反向传播(backpropagation)就是那个高效算法。 它常被传得很神秘,但其实只是一个东西:链式法则,加一点耐心。

读完这一章,你会明白

  • 链式法则的直觉:把“最终的错”一段段分配责任回去;
  • 反向传播的三步:从输出误差出发 → 逐层往回传 → 算出每个权重和偏置的梯度;
  • 怎么按真实计算顺序,把配套速查图里的公式(含 wb)一条条现场推出来(而不是死记);
  • 为什么前向时要存下每层的 z(第 3 章那个伏笔);
  • 逐行读懂真实的反向传播实现;
  • 矩阵怎么求导:为什么 ∂L/∂W = Xᵀ·G,以及它凭什么等于“一堆 MNIST 式标量求导打包成一次矩阵乘”——这是大模型梯度不爆炸的关键。

1. 链式法则:一段段地追责任

先看最简单的情形:一个参数 w,它影响 z,z 影响激活值 a, a 最终影响损失 L。这是一条“影响链”:

w z a L ∂z/∂w ∂a/∂z ∂L/∂a 梯度反着传回来:三段局部导数连乘

前向:w → z → a → L。反向:把 L 的误差,按每一段的局部导数连乘,传回 w。

要知道“w 动一点,L 会变多少”,链式法则说:把这条链上每一段的局部导数乘起来:

Lw = La · az · zw 总影响 = 损失对 a 的影响 × a 对 z 的影响 × z 对 w 的影响

偏置 b 更省事。一个神经元里 z = Σ w·a + b, 对 b 求导时那些带 w 的项都是常数、导数为 0,只剩 b 自己,系数是 1——也就是 ∂z/∂b = 1。 于是同一条链走到最后一段换成 1,偏置的梯度就直接等于L/∂z(也就是下一节要反复出现的 δ),连输入都不用再乘:

Lb = La · az · zb = La · az · 1 和上面 ∂L/∂w 只差最后一段:w 那段是输入 a,b 这段是 1
一个类比

像追查一次生产事故的责任:最终的损失(成品报废)要追到某个工人(参数)头上, 得沿着流水线一站站往回问“你这一步对最终结果影响多大”,把这些“影响”乘起来, 就得到这个工人应负的总责任。反向传播,就是给每个参数同时做这件追责。

2. 反向传播的三步

把上面的思路推广到整张多层网络,就是干净利落的三步。我们引入一个中间量 δ(delta),表示“某个神经元对最终损失的责任”。

第一步:从输出层的误差开始

反向传播的起点是最后一层。它的 δ 由“损失对输出的导数”乘上“激活函数的导数”得到:

δ末层 = La · f′(z) 用到了第 4 章的 DerivLoss第 2 章的 DerivActivate

还记得第 4 章那个美妙巧合吗?如果用 softmax + 交叉熵,这一步直接简化成 δ = pt

第二步:把误差一层层往回传

有了后一层的 δ,前一层的 δ 就能算出来:把后一层的责任,按连接它们的权重“分摊”回来,再乘上本层激活的导数:

δl = ( Wl+1T δl+1 ) ⊙ f′(zl) ⊙ 表示逐元素相乘;WT 是把权重“反过来用”,把后层责任分回前层

第三步:用 δ 算出每个参数的梯度

一旦每个神经元都有了自己的 δ,梯度就唾手可得——权重的梯度,是“它两端的 δ 和输入”的乘积;偏置的梯度,就是 δ 本身(还记得吗?偏置那段局部导数是 1):

Lwl,o,i = δl,o · al−1,i      Lbl,o = δl,o 权重梯度 = 本神经元的 δ × 上一层对应的输入;偏置梯度就是 δ 本身
框架到手:先带下标推一遍,再看动画回放

这三步就是反向传播的全部骨架。但光看通用公式容易“懂了又没懂”。 所以下面先拿一张真实的速查图、带具体下标,把每个 δ、每根 w、每个 b 的梯度按真实计算顺序现场推一遍; 推完再用动画把整个流动回放一遍(误差怎么从末层反着流回来)——你会看到,推来推去都没跳出这三步。

3. 跟着速查图,按真实计算顺序推一遍

链式法则(第 1 节)和三步框架(第 2 节)都到手了,现在拿它们去啃一张真实的速查图:把抽象公式落到具体下标上。 这张图乍看是一堵“公式墙”,分成 struct / base / neuron / formula / example 五块。 但这五块只是速查时的分区,不是计算顺序——base 把零件摊在那儿,formula 写通用模板,example 才代入具体下标。 真要看懂,得按一次训练真正发生的顺序走:前向逐个神经元算 → 到输出算损失 → 从末层往回推每个梯度 → 最后才更新参数。 下面就按这个顺序,用刚学的链式法则,把图里的公式一条条现场推出来——每冒出一条,都先说清“这一步为什么需要它”。

深度学习速查图:多层网络结构、基础公式与反向传播求导示例

别按区块顺序读;跟着下面第 1~5 步走,在图上找到对应位置即可。

读图约定:先认下标(struct 区)

图左上 struct 是一张缩小版 MLP,和仓库里 MNIST 的 784→128→64→10 同类,只是画小了方便手算:

权重 w 的三位下标 = 连到第几层 / 本层第几个神经元 / 上一层第几个输入。 例:w100 是“输入 0 → 第 1 层 0 号”那根线;w210 是“第 1 层 0 号 → 第 2 层 1 号”。 out20 则是第 2 层 0 号神经元的输出。认准这套记法,后面每条公式都能在图上指出来。

第 1 步 · 前向:一个神经元怎么算(neuron 区 + base 前两行)

前向时数据从左往右流。以第 1 层 0 号神经元为例,它只干两件事——先把连进来的输入加权求和,再过一次激活函数:

net10 = out00·w100 + out01·w101 + b10 = Σ out·w + b
out10 = sigmoid(net10) 就是 base 里 “Σ w·input+b = net” 和 “sigmoid(net) = output” 落到 neuron10 上(图 neuron 区)

每个神经元都这么算:第 1 层算完喂给第 2 层,第 2 层喂给第 3 层,一路到 out30一个要记住的细节:算 net 时顺手把它存下来(代码里的 neuron_preact_,见第 3 章),第 3、4 步反向求导还要回头用它。

第 2 步 · 前向到头,量一下错得多离谱(base 的 Etotal)

数据流到输出层,out30 就是这次的预测。拿它和 target 比,用均方误差(MSE)压成一个数:

Etotal = 12n Σ |targetoutputn 是输出神经元个数;本图只有 out30 一个,所以 n=1

这个标量就是“错得多离谱”。想让它变小,就得知道每个权重该往哪调、调多少——也就是对每个 wEw。正是这一问,才逼出下面的反向传播。

(图 base 区还列了 Softmax:本图单输出配 MSE,用不到它;多分类如 MNIST 10 类才用 softmax 把多个 net 变成概率分布,见第 23 章。)

第 3 步 · 反向第一站:输出层的 w300 和 b30(base 的 ∂E/∂out、S′ → example A)

损失有了,现在要梯度。先挑最靠近损失的一根权重 w300——它只经一条路影响损失:w300 → net30 → out30 → E。链式法则把这条路上三段局部导数连乘:

Ew300 = Eout30 · out30net30 · net30w300

三段挨个算,零件全在 base 区现成:

  1. E/∂out30 = −(targetout30) —— 对第 2 步的 MSE 求导,得 base 里的 −1n(targetout),n=1 就是它。预测比目标大时为正,提示“该往下调”。
  2. out30/∂net30 = out30(1 − out30) —— 因为 out30=sigmoid(net30),直接套 base 里的 S′(x)=S(x)(1−S(x))。
  3. net30/∂w300 = out20 —— 因为 net30 = out20·w300 + out21·w301 + b30,对 w300 求导只剩它的系数 out20

把前两段(只跟这个神经元自己有关、后面反复要用)打包起个名字叫 δ30,连乘就收成一行:

δ30 = Eout30 · out30net30 = −(targetout30out30(1−out30)
E/∂w300 = δ30 · out20      ∂E/∂b30 = δ30 这就是图 example 区头两行

b30 的梯度怎么来的? 一模一样的链子,只是最后一段换成偏置: b30 → net30 → out30 → E。前两段和 w300 共用,正是 δ30; 最后一段回到第 1 步那个式子 net30 = … + b30,对 b30 求导 = 1。所以 ∂E/∂b30 = δ30 · 1 = δ30——偏置梯度就等于它自己神经元的 δ,连 out 都不用乘。

图 formula 区那句 δlast = ∂E/∂outlast · ∂outlast/∂netlast,不过是给“前两段乘积”起的通用名而已。

第 4 步 · 反向往里走:隐藏层的 w100 和 b10(formula 的 δ 递推 → example B)

再挑一根深处的 w100。它离损失远,而且 out10 同时连去第 2 层两个神经元(b20b21), 所以它对损失的影响要把两条路加起来。整条链照样连乘:

Ew100 = Eout10 · out10net10 · net10w100

难点只在第一段 ∂E/∂out10:out10 要经 b20b21 才影响到 E,于是把这两条路求和:

Eout10 = Σo∈{20,21} ( δo · wo ) = δ20·w200 + δ21·w210 δ20δ21 就是第 3 步那种“前两段乘积”,已在上一层算好;w200/w210out10 连出去的两根权重

另外两段照旧:∂out10/∂net10 = out10(1−out10),∂net10/∂w100 = out00。三段合起来,同样打包出 δ10:

δ10 = ( δ20·w200 + δ21·w210 ) · out10(1−out10)
E/∂w100 = δ10 · out00      ∂E/∂b10 = δ10 这就是图 example 区后两行;formula 区通用式 δo=(Σ δo+1·wo+1)·∂outo/∂neto 就是它的模板

b10 的梯度也一并说清(这根最容易被跳过):偏置走的链子是 b10 → net10 → out10 → …… → E, 和 w100 共用后面全部路段,唯一不同还是最后一段——回到第 1 步 net10 = out00·w100 + out01·w101 + b10, 对 b10 求导,带 w 的项全变 0,只剩系数 1,即 ∂net10/∂b10 = 1。 于是 ∂E/∂b10 = δ10 · 1 = δ10规律一句话:任何一根偏置的梯度,都等于它所在神经元的 δ;权重才要再乘一个“上一层输入”。

这一步才说清“反向”二字

δ10 得先有 δ20δ21;要它们又得先有 δ30。 所以只能从末层往前一层层算——前一层刚算出的 δ,正好喂给更前面一层用。 这就是“反向”传播名字的由来,也是它高效的原因:每个 δ 只算一次,之后重复利用。 对应代码里的倒序循环 for (l = last-1; l >= 1; l--)

第 5 步 · 梯度全到手,最后才更新(base 的 w⁺)

把每一层都这么从后往前扫一遍,网络里每个 w、每个 b 都拿到了自己的梯度。到这一步,也只有到这一步,才轮到更新参数:

w⁺ = wη · Ew η 是学习率;反向传播只算出 ∂E/∂w,真正挪参数是优化器的活

注意 w⁺ 虽然写在 base 区里,却是整个流程最后才发生的一步。“反传求梯度”和“更新参数”是两码事——具体怎么挪,见第 5 章的梯度下降和第 8 章的各种优化器。

一次训练步的真实顺序(回头对速查图)

①前向 每个神经元 netout,逐层算到 out30(neuron 区 / base 前两行) → ②损失 Etotal 量误差(base) → ③④反向 δ 从末层往前推,顺手得每根 wb 的梯度(formula 写通用式,example 代入下标) → ⑤更新 w⁺ = wη·∂E/∂w(base)。
速查图五块只是把这五步用到的零件分门别类摆好;MNIST 的 784→128→64→10 也只是把输入变宽、层加深,这套顺序一字不改

4. 边看动画边回放一遍

公式一条条推完了,难免还是有点“只见树木”。再回过头看它整体动起来:还是那个动画(和第 3 章前向同一个),这次请点 “切到反向传播”,再一步步点 “下一步”。 刚才第 3 节逐个下标推的 δ,在这里就是那束从输出层反着流回输入层的暖色信号——它传的不是数据,而是“误差/责任”。 你还可以 hover 公式里的彩色项,看它对应网络里的哪个节点或哪条边,把“推导”和“流动”对上号。

前向(蓝绿)把数据往前算;反向(暖色)把误差往回传。两趟合起来,就是一次完整的训练步。

5. 逐行读懂反向传播

真实代码就是上面三步的忠实翻译。第一步:末层的 δ。

src/deeplearning/neural_network.cpp · BackPropagationBatch ①末层(精简)
if (use_softmax) {
  delta = softmax_function_->CalcDelta(output, target, loss_function_);  1
} else {
  double dL = loss_function_->DerivLoss(target, output) / out_dim;       2
  delta = dL * activate_function_->DerivActivate(preact, output);        3
}
neuron_delta_[last][b][o] = delta;                                       4
  1. 若是 softmax + 交叉熵,直接用那个漂亮结果 δ = pt
  2. 否则,先算损失对输出的导数 DerivLoss(第 4 章)。
  3. 再乘上激活函数的导数 DerivActivate。注意它同时用到了 preact(也就是前向存下的 z)和 output——这就是第 2、3 章埋的伏笔:有些激活(如 GELU)必须知道 z 才能求导。
  4. 把末层每个神经元的 δ 存进 neuron_delta_,作为往回传的起点。

第二步:把 δ 一层层往回传。

src/deeplearning/neural_network.cpp · BackPropagationBatch ②回传(精简)
for (int l = last - 1; l >= 1; l--) {            1
  for (int o = 0; o < layer_[l]; o++) {
    double sum = 0.0;
    for (int k = 0; k < layer_[l + 1]; k++)
      sum += neuron_weight_[l + 1][k][o]
             * neuron_delta_[l + 1][b][k];        2
    neuron_delta_[l][b][o] =
        sum * activate_function_->DerivActivate(
                  neuron_preact_[l][b][o],
                  neuron_output_[l][b][o]);        3
  }
}
  1. 注意循环方向:l 从倒数第二层往前走到第 1 层——这就是“反向”二字的由来。
  2. 把后一层每个神经元的责任 δ,乘上连接的权重,累加起来——这正是公式里的 WTδ,把后层责任分摊回当前神经元。
  3. 再乘上当前层激活的导数,得到当前层的 δ。这一行就是 δl = (WTδ) ⊙ f′(z)。

第三步:用 δ 把梯度累加出来。

src/deeplearning/neural_network.cpp · BackPropagationBatch ③累加梯度(精简)
for (int l = 1; l < L; l++) {
  const auto &in_vec = neuron_output_[l - 1][b];
  for (int o = 0; o < layer_[l]; o++) {
    double d = neuron_delta_[l][b][o];
    grad_bias_[l][o] += d;                        1
    for (int i = 0; i < layer_[l - 1]; i++)
      grad_weight_[l][o][i] += d * in_vec[i];     2
  }
}
  1. 偏置的梯度,就是这个神经元的 δ 本身(对应公式 ∂L/∂b = δ)——和第 3 节、第 3~4 步推的完全一致,代码里就一句 grad_bias_[l][o] += d
  2. 权重的梯度,是 δ 乘以“上一层对应的输入”(对应 ∂L/∂w = δ·a)。注意是 +=:一批里每个样本都累加进来,最后在 ApplyGradient 里除以 B 取平均(第 5 章)。
前向 + 反向 = 一次完整的训练步

把这一章和前几章串起来:前向算出预测(第 3 章)→ 损失衡量错误(第 4 章)→ 反向算出每个参数的梯度(本章)→ 优化器按梯度更新参数(第 5 章)。 这四步循环成千上万次,网络就“学”会了。这就是第 0 章那个训练循环的完整真相。

常见误区:反向传播 ≠ 更新参数

反向传播只做一件事——算出每个参数的梯度(“该往哪调、调多少”的建议)。 至于真正怎么挪参数,是优化器的活儿(第 8 章):SGD 直接 w −= 学习率 × 梯度, Adam 则用动量 + 自适应步长。所以“反传”和“更新”是两个步骤,别混为一谈—— 在很多框架里它们也确实是分开的两次调用(先 backward() 求梯度,再 optimizer.step() 更新)。

6. 从一个数到一整块:矩阵是怎么求导的

到这里你可能有个很实在的困惑:上面推的 δ·a 全是一个数对一个数求导(和 MNIST 里那个 double 一样)。 可到了第 18 章的 Transformer,一层就是 Y = X·W——X 是矩阵、W 是矩阵、输出还是矩阵。 那“矩阵对矩阵求导”是个什么怪物?导数难道是个四维的东西?这一节把它讲穿。(其中“梯度和参数同形状、Xᵀ·G 怎么来的”这套数学基础,第 1 章 §7 已经先垫过,这里落到反向传播上。)

先破除误解:根本没有“矩阵对矩阵”这种恐怖东西

记住一条铁律:梯度永远和参数本身同形状W[d×d] 的矩阵,那 ∂L/∂W 也就是个 [d×d] 的矩阵—— 里面每一个 ∂L/∂W[i][j] 都只是一个普通的数,就是“loss 对这一个权重的偏导”,和 MNIST 那个 double 一模一样。 所以“对矩阵求导”不是新东西,它只是d×d 个“MNIST 式的标量求导”打包放进一张同样大小的表。 你不是在对“矩阵”求导,你是在同时对矩阵里每个格子求导,结果自然排成同样形状的一张表。

从单个权重推起:还是那条链式法则

拿一层 Y = X·W。先只盯一个权重 Wkj(纯 MNIST 式,一个 double)。前向时,输出矩阵的某个元素是一次点积:

Yij = Σk Xik · Wkj 输出第 i 行第 j 列 = 输入第 i 行 与 W 第 j 列 的点积(第 1 章)

现在问:Wkj一个数动一点,loss 变多少?套链式法则(和 MNIST 一字不差), 只是要把它影响到的所有输出位置 Yij 的责任都加起来:

LWkj = Σi LYij · YijWkj = Σi Gij · Xik G = ∂L/∂Y 是上游传下来的梯度(和 Y 同形状);∂Yij/∂Wkj = Xik

关键在最右边那个 Σi Xik·Gij——它正好就是矩阵乘法 Xᵀ·G 的第 [k][j] 个元素! 于是把所有 kj 一起写,一整块 W 的梯度就是:

LW = XT · G 一次矩阵乘,就把 W 里几百万个参数的梯度全部同时算出来了
这就是全部魔法

你本来要对 d×d 个权重逐个做 MNIST 式求导,但因为每个格子的公式长得一模一样、只是下标不同, 它们天然拼成了一次矩阵乘法。矩阵求导不是一套新规则,而是把一大堆相同的标量求导“批发”成一次矩阵运算。 这也顺带回答了一个常见担心:更新 W 里几百万个参数,不是循环几百万次,而是一次矩阵乘——GPU 一步就刷完。

线性层的“反向三件套”与形状对齐

于是 Y = X·W 这一层,反向时收到上游 G = ∂L/∂Y,本地只需算两样(偏置略):

L/∂W = XT · G  ·  ∂L/∂X = G · WT 左边:参数梯度(拿去更新 W,形状 = W);右边:输入梯度(往下游继续传,形状 = X)

怎么记住谁转置、谁乘谁?一个特别好用的窍门:梯度必须和它对应的东西同形状,反过来用形状凑就行。 设 X[n×d]W[d×d],那 GY 同形状也是 [n×d]:

要算的梯度必须的形状只有这么乘才对
∂L/∂W[d×d](同 W)Xᵀ·G = [d×n]·[n×d] = [d×d]
∂L/∂X[n×d](同 X)G·Wᵀ = [n×d]·[d×d] = [n×d]

形状对齐几乎唯一地决定了公式长什么样——这也是工程上很少把反向公式推错的原因:凑不出正确形状的写法,一定是错的。

MNIST(标量)Transformer(矩阵)
参数一个数 w一块 W [d×d]
它的梯度一个数 ∂L/∂w一块 ∂L/∂W,同样 [d×d],每格是个标量偏导
怎么算δ·x(一次乘)Xᵀ·G(一次矩阵乘 = 把所有 δ·x 批量做完)
本质链式法则同一个链式法则,只是打包成矩阵

所以“大模型的梯度”听着玄,拆开就是这张表:MNIST 的标量求导 × 打包成矩阵乘

整个几十层的大模型,反向传播就是把这套“收到上游 G → 本地算 Xᵀ·GG·Wᵀ → 把 G·Wᵀ 往前传”在每一层接力一遍。 从 loss 出发倒着扫一遍,所有层、所有参数的梯度就都齐了——代价大约只等于 2 次前向传播,和参数量几乎无关。 真实框架里这套“记住前向每一步、反向自动套用局部公式”的机制叫 自动微分(autograd),所以工程师只写前向,从不手推几十亿参数的梯度。 (第 20 章那个 FLOPs ≈ 6 × 参数 × token 的“6”,拆开就是前向 2 + 反向 4——反向按算力算比前向再贵一点,但仍是常数倍,不随参数量膨胀。)

那大模型训练到底“爆”在哪?

既然算梯度只要约 2 倍前向,大模型训练难在别处:① 显存——反向要用到前向存下的中间激活值,层多序列长时能吃光显存(解法是 gradient checkpointing:反向时重算一部分激活,拿算力换显存); ② 数值稳定——梯度穿过很多层连乘,可能爆炸或消失,这正是残差连接LayerNorm第 9 章梯度裁剪要解决的; ③ 多卡同步——梯度要在上千张卡间 AllReduce 求平均(第 21 章)。计算量从不是瓶颈,这三样才是。

小结

  • 反向传播 = 链式法则:把“最终损失”按每段局部导数连乘,分配责任回每个参数。
  • 三步:① 末层 δ = ∂L/∂a · f′(z)(softmax+CE 时就是 pt);② 逐层回传 δl = (WTδ) ⊙ f′(z);③ 梯度 = δ × 输入。
  • 权重梯度 = δ × 上一层输入;偏置梯度 = δ 本身(因为 ∂net/∂b = 1)。
  • 配套速查图五块:struct 看结构,base/neuron 看前向,formula/example 看反向,最后 w⁺ 才是更新;按“前向→损失→反向→更新”的真实顺序读,而不是按区块。
  • 反向时要用前向存下的 z(neuron_preact_)来算激活导数。
  • 矩阵求导 = 标量求导批发:∂L/∂WW 同形状,每格是个标量偏导;线性层 Y=X·W 的梯度是 ∂L/∂W=Xᵀ·G∂L/∂X=G·Wᵀ,靠形状对齐就能记住。
  • 大模型算全部梯度只需约 2 倍前向(autograd 自动完成),真正的瓶颈是显存(激活)、数值稳定、多卡同步,不是计算量。
  • 前向 → 损失 → 反向 → 更新,四步一循环,反复多次即为训练。

动手与思考

问题 1:为什么叫“反向”传播?

因为计算方向和前向相反:前向是数据从输入流到输出;反向是误差(δ)从输出层一层层流回输入层。代码里那句 for (l = last-1; l >= 1; l--) 的倒序循环就是它的体现。

问题 2:速查图里 ∂E/∂w100 为什么要先算 δ10?

w100 在隐藏层,δ10 不是直接从损失来的,要把后面 b20b21 的责任经 w200w210 汇总回来,再乘 S′(net10)。有了 δ10 才能乘输入 out00 得到这根权重的梯度。

问题 3:同一个神经元,它的偏置 b 和权重 w 的梯度算法差在哪?

两者共用同一个 δ(前面所有路段都一样),只差最后一段局部导数:权重那段是 ∂net/∂w = 上一层的输入,所以 ∂L/∂w = δ × 输入;偏置那段是 ∂net/∂b = 1,所以 ∂L/∂b = δ × 1 = δ 本身,连输入都不用乘。

问题 4:权重 wl,o,i 的梯度由哪两样东西相乘得到?

由“这个权重指向的神经元的 δ(neuron_delta_[l][o])”乘以“它接收的那个输入(上一层的输出 in_vec[i])”。直觉:责任越大、输入越强,这个权重就该调得越多。

问题 5:如果前向时没有保存每层的 z,反向传播会怎样?

很多激活函数(尤其 GELU)的导数需要原始的 z 才能算准,缺了它就只能用粗略近似,梯度不准、训练变差。所以前向时顺手把 z 存进 neuron_preact_ 是必要的。

反向传播搞定,“学习”的完整闭环就通了。但同一条链路里,用哪种激活函数会直接影响梯度好不好传。 下一章我们就把 Sigmoid、ReLU、GELU 这些激活函数摆到一起,看它们的曲线、导数和各自的脾气。