☰
反向传播原理详解:从链式法则到TensorFlow自动微分实战
2026/10/17 20:18:25 网站建设 项目流程

1. 反向传播到底在算什么:从一条链式法则说起

反向传播这四个字,几乎每个学深度学习的人都听过,但真正能把它讲清楚的人并不多。我见过不少朋友,TensorFlow 的model.fit()调得飞起,各种网络结构搭得眼花缭乱,可一旦被问到“梯度到底是怎么算出来的”,就开始含糊其辞。这篇笔记就基于官方文档里关于自动微分和梯度的内容,把反向传播这件事从头到尾捋一遍。

先说结论:反向传播本质上就是链式法则的高效实现。它不是什么玄学,也不是 TensorFlow 独有的黑魔法。你拿一张纸、一支笔,对一个三层全连接网络手动求导,算出来的东西和tf.GradientTape跑出来的结果是一模一样的,只不过框架帮你把中间过程自动化了,而且做得比你手算快几个数量级。

那它解决什么问题?简单说,神经网络训练的核心就是“调整参数让损失变小”。要知道参数往哪个方向调、调多少,就必须知道损失对每个参数的偏导数,也就是梯度。反向传播就是算这个梯度的算法。没有它,深度网络根本训不起来,因为参数动辄百万千万,逐个数值求导的计算量完全不可接受。

这篇内容适合谁看?如果你已经会用 TensorFlow 搭简单的模型,但对GradientTape、tape.gradient()这些 API 背后的原理一知半解,或者面试时被问到反向传播只能背个大概,那这篇就是写给你的。我会从计算图讲起,把前向传播、反向传播、梯度累加、链式法则的每一步都拆开,配上可以直接跑的代码,最后再聊聊实际用的时候容易踩的坑。

2. 计算图:反向传播的地基

2.1 为什么需要计算图

要理解反向传播,先得理解计算图。TensorFlow 这个名字里的“Flow”,说的就是张量在计算图里流动。所谓计算图,就是把一个复杂的数学运算拆成一堆简单操作的节点,用边把它们连起来,表示数据依赖关系。

举个例子,假设我们要算 $z = (x + y) \times w$。这个表达式可以拆成两个节点:一个加法节点算 $a = x + y$,一个乘法节点算 $z = a \times w$。数据从输入流向输出,这就是前向传播。而反向传播,就是沿着这张图反着走一遍,从输出端的梯度推回输入端。

为什么非要建图?因为建图之后,每个节点的局部导数都是已知的、简单的。加法节点的导数是 1,乘法节点的导数是另一个乘数。反向传播只需要把这些局部导数按链式法则乘起来,就能得到任意输入对输出的梯度。这就是它高效的根本原因——把复杂函数的求导分解成简单函数求导的组合。

2.2 前向传播的记录过程

在 TensorFlow 里,tf.GradientTape就是那个帮你“记图”的工具。它像一个录像机,把你在这个上下文里做的所有运算都录下来,形成一个计算图。等你要算梯度的时候,它把录像倒着放一遍,自动应用链式法则。

import tensorflow as tf x = tf.constant(3.0) y = tf.constant(4.0) w = tf.constant(2.0) with tf.GradientTape() as tape: tape.watch([x, y, w]) # 常量默认不被追踪,需要手动 watch a = x + y # a = 7.0 z = a * w # z = 14.0 dz_dx = tape.gradient(z, x) dz_dy = tape.gradient(z, y) dz_dw = tape.gradient(z, w) print(dz_dx, dz_dy, dz_dw) # 2.0 2.0 7.0

这段代码里,tape.watch()是关键。TensorFlow 默认只追踪tf.Variable类型的变量,因为训练时我们要更新的就是变量。但如果你想对常量求导,就必须显式告诉 tape:“这个也帮我盯着”。很多人第一次用GradientTape求出来是None,八成就是忘了 watch。

2.3 反向传播的数学推导

还是上面那个例子,我们手动推一遍。$z = (x+y) \cdot w$,要求 $\frac{\partial z}{\partial x}$、$\frac{\partial z}{\partial y}$、$\frac{\partial z}{\partial w}$。

按链式法则:

  • $\frac{\partial z}{\partial w} = \frac{\partial z}{\partial z} \cdot \frac{\partial z}{\partial w} = 1 \cdot (x+y) = 7$
  • $\frac{\partial z}{\partial x} = \frac{\partial z}{\partial a} \cdot \frac{\partial a}{\partial x} = w \cdot 1 = 2$
  • $\frac{\partial z}{\partial y} = \frac{\partial z}{\partial a} \cdot \frac{\partial a}{\partial y} = w \cdot 1 = 2$

结果和代码输出完全一致。这里有个细节值得注意:反向传播是从输出往输入推的,每一步都是“上游梯度 × 本地梯度”。上游梯度就是损失对当前节点输出的偏导,本地梯度就是当前节点输出对输入的偏导。两者相乘,就得到损失对当前节点输入的偏导。这个模式会一直重复到所有输入节点。

提示:理解“上游梯度 × 本地梯度”这个模式,比死记链式法则公式有用得多。几乎所有反向传播的推导,都是这个模式的反复应用。

3. 从标量到张量:梯度是怎么组织的

3.1 雅可比矩阵与向量化

上面的例子是标量对标量求导,简单。但实际网络里,输入输出都是张量,梯度就变成了雅可比矩阵。假设一个函数 $f: \mathbb{R}^n \to \mathbb{R}^m$,它的雅可比矩阵 $J$ 是一个 $m \times n$ 的矩阵,第 $i$ 行第 $j$ 列的元素是 $\frac{\partial f_i}{\partial x_j}$。

如果老老实实算雅可比矩阵,参数量一大就爆了。所以实际实现里,反向传播从来不显式构造雅可比矩阵,而是用向量-雅可比积(VJP)的方式,直接算“上游梯度向量 × 雅可比矩阵”,得到对输入的梯度。这样计算量就降下来了。

TensorFlow 的tape.gradient(target, sources)做的就是这件事。target是标量损失,sources是变量列表,返回的梯度结构和sources一一对应。如果target不是标量,TensorFlow 会默认对它求和变成标量再求导,这一点很多人不知道,容易在自定义损失时出问题。

3.2 一个多变量实例

import tensorflow as tf x = tf.Variable([1.0, 2.0, 3.0]) W = tf.Variable([[1.0, 0.0], [0.0, 1.0], [1.0, 1.0]]) b = tf.Variable([0.5, -0.5]) with tf.GradientTape() as tape: y = tf.matmul(tf.expand_dims(x, 0), W) + b # shape (1, 2) loss = tf.reduce_sum(y ** 2) grads = tape.gradient(loss, [x, W, b]) for g, name in zip(grads, ['x', 'W', 'b']): print(name, g.numpy())

这里loss是标量,x、W、b都是变量。tape.gradient返回的梯度形状和变量本身完全一致,x的梯度是长度 3 的向量,W的梯度是 3×2 的矩阵,b的梯度是长度 2 的向量。这种“形状对齐”是 TensorFlow 的设计约定,方便后续直接用优化器更新。

3.3 梯度累加与多次调用

GradientTape默认只能求一次梯度,求完就释放了。如果你在同一个 tape 上调用两次gradient(),第二次会报错。这是设计上的取舍——为了省内存,tape 记录的信息在第一次反向传播后就被清掉了。

如果需要多次求导,比如算二阶导数,要用嵌套的 tape:

x = tf.Variable(3.0) with tf.GradientTape() as outer_tape: with tf.GradientTape() as inner_tape: y = x ** 3 dy_dx = inner_tape.gradient(y, x) # 3x^2 = 27 d2y_dx2 = outer_tape.gradient(dy_dx, x) # 6x = 18 print(dy_dx.numpy(), d2y_dx2.numpy())

嵌套 tape 的写法在实现物理信息神经网络、梯度惩罚等场景里很常见。注意外层 tape 要能追踪到dy_dx的计算过程,所以inner_tape.gradient这一步必须在外层 tape 的上下文里执行。

4. 手写一个迷你反向传播:不依赖自动微分

4.1 用 NumPy 实现两层网络

光看 API 还不够过瘾,我们干脆用 NumPy 手写一遍反向传播,把每个梯度都推出来。这样你对 TensorFlow 到底帮你做了什么,会有完全不一样的认识。

import numpy as np # 数据:4 个样本,每个 3 维特征 X = np.array([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0], [7.0, 8.0, 9.0], [1.0, 0.0, 1.0]]) y = np.array([[1.0], [0.0], [1.0], [0.0]]) # 初始化参数 np.random.seed(42) W1 = np.random.randn(3, 4) * 0.1 b1 = np.zeros((1, 4)) W2 = np.random.randn(4, 1) * 0.1 b2 = np.zeros((1, 1)) def sigmoid(z): return 1 / (1 + np.exp(-z)) def sigmoid_grad(a): return a * (1 - a) lr = 0.1 for epoch in range(1000): # 前向传播 z1 = X @ W1 + b1 a1 = sigmoid(z1) z2 = a1 @ W2 + b2 a2 = sigmoid(z2) # 损失:均方误差 loss = np.mean((a2 - y) ** 2) # 反向传播 dloss_da2 = 2 * (a2 - y) / y.shape[0] # (4,1) da2_dz2 = sigmoid_grad(a2) # (4,1) dz2 = dloss_da2 * da2_dz2 # (4,1) dW2 = a1.T @ dz2 # (4,1) db2 = np.sum(dz2, axis=0, keepdims=True) # (1,1) da1 = dz2 @ W2.T # (4,4) dz1 = da1 * sigmoid_grad(a1) # (4,4) dW1 = X.T @ dz1 # (3,4) db1 = np.sum(dz1, axis=0, keepdims=True) # (1,4) # 参数更新 W1 -= lr * dW1 b1 -= lr * db1 W2 -= lr * dW2 b2 -= lr * db2 if epoch % 200 == 0: print(f"epoch {epoch}, loss {loss:.4f}")

4.2 每一步的梯度来源

上面这段代码里,反向传播的每一步都对应一个明确的数学操作。我把它拆开讲:

  • dloss_da2:损失对输出层激活值的梯度。均方误差 $L = \frac{1}{N}\sum(a_2 - y)^2$,对 $a_2$ 求导得 $\frac{2}{N}(a_2 - y)$。
  • da2_dz2:sigmoid 的导数,$a(1-a)$。
  • dz2:两者相乘,得到损失对输出层线性输出的梯度。
  • dW2 = a1.T @ dz2:这是矩阵形式的链式法则。因为 $z_2 = a_1 W_2 + b_2$,所以 $\frac{\partial L}{\partial W_2} = a_1^T \cdot \frac{\partial L}{\partial z_2}$。
  • da1 = dz2 @ W2.T:把梯度从输出层传回隐藏层。
  • dz1 = da1 * sigmoid_grad(a1):再乘上隐藏层激活函数的导数。
  • dW1 = X.T @ dz1:同理,得到第一层权重的梯度。

这套流程和 TensorFlow 内部做的事情本质相同,只不过框架用计算图和自动微分把这一切自动化了,而且支持任意复杂的网络结构。

4.3 和 TensorFlow 结果对照

把同样的数据和初始化参数喂给 TensorFlow,用GradientTape求梯度,你会发现数值几乎完全一致(浮点误差范围内)。这个对照实验我强烈建议你自己跑一遍,跑完之后对“自动微分”这四个字就不会再有神秘感了。

注意:手写反向传播时,最容易出错的地方是矩阵乘法的转置和维度对齐。建议每写一步就打印一下 shape,确认维度匹配。我当初学的时候,光调维度就调了一下午。

5. 梯度消失、梯度爆炸与反向传播的关系

5.1 为什么深网络难训练

反向传播是从后往前逐层乘梯度的。如果每一层的本地梯度都小于 1,乘着乘着梯度就趋近于零,前面的层几乎收不到有效的更新信号,这就是梯度消失。反过来,如果每层梯度都大于 1,乘起来就会指数级增长,变成梯度爆炸。

这个问题在 sigmoid 激活函数上特别明显,因为 sigmoid 的导数最大只有 0.25。一个 10 层的网络,梯度传到第一层时至少被缩小了 $0.25^{10} \approx 10^{-6}$ 倍,基本等于没了。这也是为什么现在隐藏层普遍用 ReLU 及其变体,ReLU 在正区间的导数是 1,不会持续缩小梯度。

5.2 实际排查手段

在 TensorFlow 里,你可以直接打印每层的梯度范数来诊断:

with tf.GradientTape() as tape: logits = model(x_batch, training=True) loss = loss_fn(y_batch, logits) grads = tape.gradient(loss, model.trainable_variables) for var, g in zip(model.trainable_variables, grads): if g is not None: print(f"{var.name}: mean={tf.reduce_mean(tf.abs(g)).numpy():.6f}, " f"max={tf.reduce_max(tf.abs(g)).numpy():.6f}")

如果发现某些层的梯度均值在 $10^{-7}$ 以下,基本可以判定梯度消失了。对应的解决手段包括:换 ReLU 激活、加 BatchNormalization、用残差连接、或者用梯度裁剪防止爆炸。

# 梯度裁剪 grads, _ = tf.clip_by_global_norm(grads, clip_norm=1.0) optimizer.apply_gradients(zip(grads, model.trainable_variables))

tf.clip_by_global_norm会把所有梯度的全局范数缩放到不超过clip_norm,这是训练 RNN、Transformer 时的标配操作。

5.3 梯度检查的实用技巧

调试自定义层或自定义损失时,梯度算错了很难发现,因为损失可能还是在下降,只是收敛得慢或者收敛到次优解。这时候可以用数值梯度检查:

def numerical_gradient(f, x, eps=1e-4): grad = np.zeros_like(x) for i in range(x.size): x_flat = x.flatten() x_flat[i] += eps f_plus = f(x_flat.reshape(x.shape)) x_flat[i] -= 2 * eps f_minus = f(x_flat.reshape(x.shape)) grad.flatten()[i] = (f_plus - f_minus) / (2 * eps) return grad

数值梯度的精度不高,但胜在绝对不会算错。拿它和tape.gradient的结果对比,如果相对误差在 $10^{-5}$ 量级,说明反向传播实现正确。这个技巧在实现复杂算子时特别有用。

6. 常见问题与排查速查

6.1 梯度返回 None 的几种情况

用GradientTape时,tape.gradient()返回None是最常见的报错。原因通常有这几类:

现象可能原因解决办法
对常量求导返回 None常量默认不被追踪用tape.watch()显式监听
变量在 tape 外定义且未参与运算计算图中没有该变量的路径确认变量参与了 tape 内的运算
用了tf.stop_gradient()人为切断了梯度检查是否有意为之
整数类型张量整数不可导转成tf.float32
用了 NumPy 运算而非 TF 运算NumPy 操作不被记录改用tf.*对应函数

6.2 梯度为 NaN 或 Inf

训练中途 loss 突然变成 NaN,十有八九是梯度爆炸或者除零。排查顺序建议这样:

  1. 先看学习率是不是太大,试试降到原来的十分之一。
  2. 检查损失函数里有没有log(0)或除以零的操作,加个epsilon保护。
  3. 打印梯度范数,确认是不是爆炸了,是的话加梯度裁剪。
  4. 检查数据里有没有 NaN 或异常大的值。
# 安全的交叉熵 loss = tf.reduce_mean( -tf.reduce_sum(y_true * tf.math.log(y_pred + 1e-7), axis=-1) )

6.3 自定义层的梯度陷阱

写自定义层时,如果call()方法里用了 Python 原生运算而不是 TensorFlow 运算,梯度就传不回来。比如x ** 2在 TF 里是可导的,但如果你把张量转成 NumPy 数组再算,梯度链就断了。记住一个原则:在GradientTape上下文里,所有运算都尽量用tf.*命名空间下的函数。

另外,自定义层里如果需要用到不可导的操作(比如取整、argmax),要用tf.stop_gradient()明确标记,否则反向传播时可能报错或者得到无意义的结果。

7. 一些个人体会

学反向传播这件事,我最大的感受是:光看公式和光跑代码都不够,必须两者结合。只看公式,容易陷入“每个符号都认识但连起来不知道在干嘛”的状态;只跑代码,又容易变成调包侠,遇到自定义需求就抓瞎。

我的建议是,找一个简单的两层网络,用 NumPy 手写一遍前向和反向,然后用 TensorFlow 实现同样的结构,逐层对比梯度数值。这个练习做下来,你对反向传播的理解会从“知道有这么回事”变成“真的懂”。之后再去看GradientTape的文档,会发现那些 API 设计得多么自然。

还有一个容易被忽略的点:反向传播本身不负责更新参数,它只负责算梯度。参数更新是优化器的事。把这两件事分开理解,很多困惑就迎刃而解了。比如为什么换个优化器训练效果差很多,但反向传播的代码一行没改——因为梯度还是那个梯度,只是怎么用梯度变了。

最后分享一个小技巧:调试梯度问题时,把 batch size 设成 1,数据设成固定值,关掉 dropout 和 batch norm 的训练模式,这样每次前向传播的结果都是确定的,梯度也应该是确定的。在这个基础上做数值梯度检查,最容易定位问题。等确认梯度没问题了,再逐步恢复正常的训练配置。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询