系列回顾:上一篇我们把导数的直觉和梯度下降的基本思路讲清楚了。这一篇是第一阶段的收官之作,我们要完整推导反向传播算法——用一个真实的两层神经网络,从损失函数出发,一步一步把每个参数的梯度算出来。这是深度学习里最重要的数学推导之一,搞懂它,你对神经网络训练的理解会从"知道"变成"真正明白"。
1986 年,Rumelhart、Hinton 和 Williams 发表了一篇论文,把反向传播算法正式引入神经网络训练。这篇论文被认为是深度学习历史上最重要的里程碑之一。
反向传播(Backpropagation,简称 Backprop)做的事情听起来简单:高效地计算神经网络中每个参数对损失函数的梯度。
为什么说"高效"?因为理论上你可以对每个参数单独用数值方法估算梯度(稍微改变参数,看损失变化多少),但如果有 6710 亿个参数,这种方法需要 6710 亿次前向传播,根本不可行。
反向传播利用链式法则,让所有参数的梯度可以在一次反向传播里同时算出来——代价只是前向传播的 2 到 3 倍左右。这个效率上的飞跃,是现代深度学习能够训练巨大模型的关键。
这篇文章,我们先把偏导数讲扎实,然后完整走一遍反向传播,最后在 DeepSeek 的训练框架里找到这一切的落地。
单变量函数 $f(x)$ 的导数,是函数值对自变量 $x$ 的变化率。上一篇讲过,不再重复。
现在的问题是:神经网络有几千亿个参数,损失函数 $L(\theta_1, \theta_2, \ldots, \theta_n)$ 是一个有几千亿个自变量的函数。对这样的函数,导数是什么?
答案是:对每一个自变量分别求导——这就是偏导数。
对于多变量函数 $f(x, y)$,对 $x$ 的偏导数定义为:
$$\frac{\partial f}{\partial x} = \lim_{h \to 0} \frac{f(x+h, y) - f(x, y)}{h}$$
和单变量导数的定义一模一样,只是强调:$y$ 保持不变,只让 $x$ 变化。
符号 $\partial$(读作 partial,中文叫"偏")专门用来标注偏导数,区别于普通导数的 $d$。
计算 $f(x, y) = x^2 y + 3xy^2 + 5$ 的偏导数。
对 $x$ 求偏导(把 $y$ 当常数):
$$\frac{\partial f}{\partial x} = 2xy + 3y^2$$
对 $y$ 求偏导(把 $x$ 当常数):
$$\frac{\partial f}{\partial y} = x^2 + 6xy$$
验证一个具体的值:在点 $(x=1, y=2)$ 处:
$$\frac{\partial f}{\partial x}\bigg|_{(1,2)} = 2(1)(2) + 3(4) = 4 + 12 = 16$$
$$\frac{\partial f}{\partial y}\bigg|_{(1,2)} = 1 + 6(1)(2) = 1 + 12 = 13$$
意思是:在 $(1, 2)$ 这个点,$x$ 每增加一点点,$f$ 以 16 倍速率增加;$y$ 每增加一点点,$f$ 以 13 倍速率增加。
把所有偏导数收集在一起,就构成了梯度向量:
$$\nabla f(x, y) = \left(\frac{\partial f}{\partial x}, \frac{\partial f}{\partial y}\right)$$
在 $(1, 2)$ 处,$\nabla f = (16, 13)$。
这个向量指向函数值增长最快的方向。梯度下降沿它的反方向 $(-16, -13)$ 走,损失减小最快。
上一篇初步介绍了链式法则,这一篇把它讲透,因为它是反向传播的心脏。
对于复合函数 $h(x) = f(g(x))$:
$$\frac{dh}{dx} = \frac{df}{dg} \cdot \frac{dg}{dx}$$
直觉:$x$ 的一个小变化,先通过 $g$ 放大(或缩小)一次,再通过 $f$ 放大(或缩小)一次,总效果是两次放大系数相乘。
当函数有多个中间变量时,链式法则变成了求和:
假设 $z = f(u, v)$,而 $u = g(x)$,$v = h(x)$,那么:
$$\frac{dz}{dx} = \frac{\partial z}{\partial u} \cdot \frac{du}{dx} + \frac{\partial z}{\partial v} \cdot \frac{dv}{dx}$$
为什么变成加法?因为 $x$ 的变化通过两条路径(经过 $u$ 的路径和经过 $v$ 的路径)都会影响到 $z$,两条路径的影响要加在一起。
这个形式在神经网络里非常常见——一个参数可能影响多个中间结果,进而影响损失函数,所有路径的影响要全部加起来。
计算图(Computational Graph)是理解反向传播最直观的工具。
把函数的每一步计算画成一个节点,节点之间用箭头连接,表示数据流向:
x → [乘以w] → z = wx → [加上b] → a = z+b → [Sigmoid] → σ → [计算Loss] → L
前向传播:从左到右,按照箭头方向计算,得到最终结果 $L$。
反向传播:从右到左,按照箭头的反方向传播梯度,每经过一个节点,就乘以该节点的局部梯度。
这个"局部梯度乘以反传回来的梯度"的过程,正是链式法则在计算图上的体现。
PyTorch 和 TensorFlow 的自动求导机制,本质上就是在自动构建和反向遍历这个计算图。
理论讲够了,我们来做一个完整的手算推导。
这是整篇文章最硬核的部分,但也是最有价值的部分。搞懂这个,你对神经网络训练的理解就真正到位了。
我们设计一个最简单的两层全连接网络,处理二分类问题:
一步一步把每个中间结果记下来,后面反向传播要用:
第一层的线性部分: $$z_1 = w_1 \cdot x + b_1$$
第一层的激活(ReLU): $$a_1 = \text{ReLU}(z_1) = \max(0, z_1)$$
第二层的线性部分: $$z_2 = w_2 \cdot a_1 + b_2$$
第二层的激活(Sigmoid): $$\hat{y} = \sigma(z_2) = \frac{1}{1 + e^{-z_2}}$$
损失函数(二元交叉熵): $$L = -[y \log \hat{y} + (1 - y) \log(1 - \hat{y})]$$
其中 $y$ 是真实标签(0 或 1),$\hat{y}$ 是模型预测的概率。
反向传播从损失函数开始,逐层往回计算梯度。目标是算出 $\frac{\partial L}{\partial w_1}$、$\frac{\partial L}{\partial b_1}$、$\frac{\partial L}{\partial w_2}$、$\frac{\partial L}{\partial b_2}$。
第一步:损失对 $\hat{y}$ 的梯度
$$\frac{\partial L}{\partial \hat{y}} = -\frac{y}{\hat{y}} + \frac{1-y}{1-\hat{y}}$$
这是对二元交叉熵直接求导的结果。
第二步:$\hat{y}$ 对 $z_2$ 的梯度(Sigmoid 的导数)
Sigmoid 有一个特别优美的导数公式:
$$\frac{\partial \hat{y}}{\partial z_2} = \sigma(z_2)(1 - \sigma(z_2)) = \hat{y}(1 - \hat{y})$$
为什么这么优美?因为 Sigmoid 的导数可以用它自己的输出值来表示,不需要重新计算,非常高效。
第三步:合并——损失对 $z_2$ 的梯度
用链式法则:
$$\frac{\partial L}{\partial z_2} = \frac{\partial L}{\partial \hat{y}} \cdot \frac{\partial \hat{y}}{\partial z_2}$$
代入化简(这一步代数有点长,但结果很干净):
$$\frac{\partial L}{\partial z_2} = \left(-\frac{y}{\hat{y}} + \frac{1-y}{1-\hat{y}}\right) \cdot \hat{y}(1-\hat{y}) = \hat{y} - y$$
这个结果非常漂亮:交叉熵损失 + Sigmoid 激活,梯度就是"预测值 - 真实值"。
这不是巧合,是精心设计的结果。交叉熵和 Sigmoid(或者 Softmax)搭配使用,就是为了得到这个干净的梯度形式,让训练更稳定高效。
第四步:损失对 $w_2$ 和 $b_2$ 的梯度
$$\frac{\partial L}{\partial w_2} = \frac{\partial L}{\partial z_2} \cdot \frac{\partial z_2}{\partial w_2} = (\hat{y} - y) \cdot a_1$$
$$\frac{\partial L}{\partial b_2} = \frac{\partial L}{\partial z_2} \cdot \frac{\partial z_2}{\partial b_2} = (\hat{y} - y) \cdot 1 = \hat{y} - y$$
$\frac{\partial z_2}{\partial w_2} = a_1$ 是因为 $z_2 = w_2 \cdot a_1 + b_2$,对 $w_2$ 求偏导结果是 $a_1$。
第五步:梯度从第二层传回第一层
继续往回传,需要计算损失对 $a_1$ 的梯度:
$$\frac{\partial L}{\partial a_1} = \frac{\partial L}{\partial z_2} \cdot \frac{\partial z_2}{\partial a_1} = (\hat{y} - y) \cdot w_2$$
梯度在传播时乘以了 $w_2$——这就是为什么权重大的层,梯度传播也更强(或者如果权重很小,梯度就会减弱)。
第六步:ReLU 的梯度
ReLU 的导数:
$$\frac{\partial a_1}{\partial z_1} = \begin{cases} 1 & \text{如果 } z_1 > 0 \\ 0 & \text{如果 } z_1 \leq 0 \end{cases}$$
这就是 ReLU 的"开关"作用:正区间梯度直接通过(乘以 1),负区间梯度直接截断(乘以 0)。
$$\frac{\partial L}{\partial z_1} = \frac{\partial L}{\partial a_1} \cdot \frac{\partial a_1}{\partial z_1} = (\hat{y} - y) \cdot w_2 \cdot \mathbb{1}[z_1 > 0]$$
其中 $\mathbb{1}[z_1 > 0]$ 是指示函数:$z_1 > 0$ 时为 1,否则为 0。
第七步:损失对 $w_1$ 和 $b_1$ 的梯度
$$\frac{\partial L}{\partial w_1} = \frac{\partial L}{\partial z_1} \cdot \frac{\partial z_1}{\partial w_1} = (\hat{y} - y) \cdot w_2 \cdot \mathbb{1}[z_1 > 0] \cdot x$$
$$\frac{\partial L}{\partial b_1} = \frac{\partial L}{\partial z_1} \cdot \frac{\partial z_1}{\partial b_1} = (\hat{y} - y) \cdot w_2 \cdot \mathbb{1}[z_1 > 0]$$
$$\frac{\partial L}{\partial w_2} = (\hat{y} - y) \cdot a_1$$
$$\frac{\partial L}{\partial b_2} = \hat{y} - y$$
$$\frac{\partial L}{\partial w_1} = (\hat{y} - y) \cdot w_2 \cdot \mathbb{1}[z_1 > 0] \cdot x$$
$$\frac{\partial L}{\partial b_1} = (\hat{y} - y) \cdot w_2 \cdot \mathbb{1}[z_1 > 0]$$
观察这些公式,你会发现一个规律:靠近输出层的梯度($w_2$, $b_2$)只依赖预测误差 $(\hat{y} - y)$;靠近输入层的梯度($w_1$, $b_1$)依赖同样的误差,但还要乘以中间层的权重和激活导数。
这正是"反向传播"名字的来源——误差从输出端一层一层往回传播,越往深处,影响链越长,公式也越复杂。
理论推导完了,有没有办法验证结果是对的?
有,叫做数值梯度检验(Gradient Check)。
方法是:对每个参数 $\theta_i$,用有限差分近似梯度:
$$\frac{\partial L}{\partial \theta_i} \approx \frac{L(\theta_i + \epsilon) - L(\theta_i - \epsilon)}{2\epsilon}$$
取一个很小的 $\epsilon$(比如 $10^{-5}$),分别算参数略大和略小时的损失,差除以 $2\epsilon$,这就是数值梯度的近似。
把解析梯度(反向传播算出来的)和数值梯度对比,如果相差很小(相对误差 $< 10^{-5}$),就说明反向传播实现对了。
PyTorch 提供了 torch.autograd.gradcheck 函数,就是在做这个检验。写自定义算子的时候,这是验证正确性的标准手段。
torch.autograd.gradcheck
手算反向传播很繁琐。实际工作中,我们用 PyTorch 这样的框架,它会自动帮你计算所有梯度。
这背后的机制叫做自动微分(Automatic Differentiation,简称 AutoDiff)。
当你在 PyTorch 里写:
import torch x = torch.tensor(2.0, requires_grad=True) w = torch.tensor(3.0, requires_grad=True) b = torch.tensor(1.0, requires_grad=True) z = w * x + b # z = 7.0 loss = z ** 2 # loss = 49.0 loss.backward() # 触发反向传播 print(w.grad) # 输出:28.0 print(x.grad) # 输出:42.0 print(b.grad) # 输出:14.0
loss.backward() 这一行,PyTorch 自动完成了所有的链式法则计算。
loss.backward()
为什么 w.grad 是 28.0?
w.grad
$loss = z^2 = (wx + b)^2$
$$\frac{\partial loss}{\partial w} = 2(wx + b) \cdot x = 2 \times 7 \times 2 = 28$$
完全吻合。
在 z = w * x + b 这行代码执行的同时,PyTorch 在后台悄悄建立了一个计算图:
z = w * x + b
w ─→ [乘法节点] ─→ z ─→ [平方节点] ─→ loss x ─↗ ↗ b ─────────────→
每个节点记录了: 1. 前向传播时的输入值 2. 对应的局部梯度计算公式
调用 backward() 时,从 loss 节点出发,沿着计算图的反方向,逐节点应用链式法则,把梯度一路传回到每个有 requires_grad=True 的叶子节点($w$、$x$、$b$)。
backward()
loss
requires_grad=True
这就是 PyTorch 的动态计算图(Dynamic Computation Graph)——代码跑的同时图就建好了,灵活、直观、调试方便。
我们现在理解了反向传播的原理,来看看在 DeepSeek 这样的大模型里,反向传播面对的是什么规模的计算。
每个 Transformer 块包含:
每一个操作都是一个节点,反向传播需要遍历所有这些节点,逐个计算梯度。
DeepSeek V3 有 61 个这样的块,每个块里的权重矩阵维度是 7168 × 7168(注意力部分)或更大(FFN 部分,因为 MoE 结构)。反向传播要对所有这些矩阵的每个元素计算梯度。
这是一个庞大的计算量,但数学结构是一样的:就是链式法则,从输出层一路传回输入层。
Attention 的前向传播公式:
$$\text{Attention}(Q, K, V) = \text{Softmax}\left(\frac{QK^T}{\sqrt{d_k}}\right)V$$
对 $W_Q$(生成 $Q$ 的权重矩阵)求梯度,需要经过:
$$L \to \text{输出} \to V \to \text{Softmax} \to \frac{QK^T}{\sqrt{d_k}} \to Q \to W_Q$$
这条链上每个步骤的导数: - $V$ 对 Softmax 输出的梯度(矩阵乘法的梯度) - Softmax 的雅可比矩阵(Softmax 的导数是一个矩阵,不是标量) - 缩放点积对 $Q$ 的梯度
每一步都是矩阵运算的梯度,形式比标量链式法则复杂,但原理完全一样——这是我们后续在注意力机制那篇会完整推导的内容。
在推导各种网络的反向传播时,下面这些公式会反复用到,值得记住:
矩阵乘法:
如果 $Z = XW$,损失对 $W$ 的梯度:
$$\frac{\partial L}{\partial W} = X^T \frac{\partial L}{\partial Z}$$
损失对 $X$ 的梯度:
$$\frac{\partial L}{\partial X} = \frac{\partial L}{\partial Z} W^T$$
这个公式在神经网络每一层都会用到,是最基础的矩阵求导结果。
加法(残差连接):
如果 $Y = X + F(X)$,对 $X$ 的梯度:
$$\frac{\partial L}{\partial X} = \frac{\partial L}{\partial Y} \cdot 1 + \frac{\partial L}{\partial Y} \cdot \frac{\partial F}{\partial X} = \frac{\partial L}{\partial Y} \left(1 + \frac{\partial F}{\partial X}\right)$$
那个"$+1$"就是残差连接防止梯度消失的数学原因——即使 $\frac{\partial F}{\partial X}$ 很小,梯度还有那条"1"的通路直接传回来。
Softmax 的梯度:
Softmax 比较特殊,它的输出之间有依赖(所有输出加起来等于 1),所以梯度是一个矩阵(雅可比矩阵):
$$\frac{\partial \hat{p}_i}{\partial z_j} = \hat{p}_i(\delta_{ij} - \hat{p}_j)$$
其中 $\delta_{ij}$ 是克罗内克符号($i=j$ 时为 1,否则为 0)。
但当 Softmax 和交叉熵损失组合使用时,梯度化简为:
$$\frac{\partial L}{\partial z_i} = \hat{p}_i - y_i$$
同样的干净形式——预测值减真实值。
现在把这些知识和 DeepSeek 的实际训练联系起来。
DeepSeek V3 的论文提到,训练使用了混合精度(BF16 + FP32)。
这和反向传播有直接关系:
为什么更新时要用 FP32?因为梯度往往很小(比如 $10^{-6}$ 量级),BF16 的精度不够,会导致大量梯度在舍入过程中变成 0,参数无法更新。FP32 有足够的精度保留这些小梯度。
这是反向传播在工程实现里的一个重要细节:数学上的"小数",在计算机里可能因为精度不够而丢失。
DeepSeek V3 使用了梯度累积(Gradient Accumulation)技术。
由于模型太大,一次性处理大批量数据显存不够,工程上的做法是:先处理小批量数据,计算梯度;不立刻更新参数,而是把梯度存起来;重复几次之后,把所有梯度加起来,才做一次参数更新。
数学上,这等价于用大批量数据训练:
$$\nabla L_{\text{total}} = \sum_{k=1}^{K} \nabla L_k$$
$K$ 次小批量的梯度加和,等价于一次大批量的梯度。这利用了梯度(偏导数)的线性叠加性质。
学完反向传播,说几个实际工作中常见的问题。
PyTorch 里,如果一个张量的 requires_grad=False,或者没有参与从 loss 到该张量的计算路径,那么它的梯度就是 None。
requires_grad=False
None
常见原因:在数据预处理时创建张量忘了设 requires_grad=True,或者某个操作用了 NumPy(不支持自动微分),切断了计算图。
PyTorch 默认是累积梯度的,每次 backward() 都会把新梯度加到旧梯度上,而不是覆盖。
所以每次迭代开始前必须手动清零:
optimizer.zero_grad() # 清零梯度 loss.backward() # 计算梯度 optimizer.step() # 更新参数
忘记 zero_grad() 是新手最常犯的错误之一,会导致梯度越积越大,训练失控。
zero_grad()
调试梯度消失时,可以打印每一层梯度的 L2 范数:
for name, param in model.named_parameters(): if param.grad is not None: print(f"{name}: grad_norm = {param.grad.norm():.6f}")
如果某一层的梯度范数接近 0,说明梯度在这里消失了,需要检查该层的激活函数或初始化方式。
这篇文章完成了第一阶段的核心任务:把反向传播从数学到工程完整打通。
第一,偏导数是固定其他变量,对单个变量求导。梯度是所有偏导数组成的向量,指向函数值增加最快的方向。
第二,多变量链式法则:当一个变量通过多条路径影响输出时,把所有路径的贡献加起来。这是神经网络反向传播的数学基础。
第三,反向传播就是从损失函数出发,沿计算图反向遍历,逐层应用链式法则,算出每个参数的梯度。我们手推了一个两层网络的完整过程。
第四,交叉熵 + Sigmoid/Softmax 的梯度有干净的形式:$\hat{y} - y$(预测值减真实值)。这不是巧合,是精心设计的数学结构。
第五,PyTorch 的自动微分通过动态计算图实现了反向传播的自动化。混合精度训练、梯度累积等工程技巧,都建立在对梯度计算的深刻理解上。
第六,残差连接的梯度包含一个"$+1$"项,这是它能有效防止梯度消失的数学原因。DeepSeek V3 每个 Transformer 块里的残差连接,都在发挥这个作用。
第一阶段完结,第二阶段预告:
从第6篇开始,我们进入线性代数的世界。
你可能觉得线性代数是枯燥的矩阵运算,但在神经网络里,每一层的计算本质上都是矩阵乘法,注意力机制的核心操作也是矩阵运算。把线性代数搞清楚,你就能真正理解 DeepSeek V3 里那些维度变换是在做什么。
第6篇,我们从最基础的向量开始,建立起对高维空间的直觉。
偏导数是感知变化的眼睛,链式法则是传递信息的神经,反向传播是让模型学会的机制。这三者合在一起,才构成了深度学习训练的完整基础。
还没有评论,来抢沙发吧!
博客管理员
40 篇文章
还没有评论,来抢沙发吧!