资讯动态

反向传播原理详解:从链式法则到TensorFlow自动微分实战

发布时间:2026/10/17 20:18:35 来源:尧图企业网站定制
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 (xy) \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 (xy) 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 记录的信息在第一次反向传播后就被清掉了。如果需要多次求导比如算二阶导数要用嵌套的 tapex 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, axis0, keepdimsTrue) # (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, axis0, keepdimsTrue) # (1,4) # 参数更新 W1 - lr * dW1 b1 - lr * db1 W2 - lr * dW2 b2 - lr * db2 if epoch % 200 0: print(fepoch {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_dz2sigmoid 的导数$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, trainingTrue) 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}, fmax{tf.reduce_max(tf.abs(g)).numpy():.6f})如果发现某些层的梯度均值在 $10^{-7}$ 以下基本可以判定梯度消失了。对应的解决手段包括换 ReLU 激活、加 BatchNormalization、用残差连接、或者用梯度裁剪防止爆炸。# 梯度裁剪 grads, _ tf.clip_by_global_norm(grads, clip_norm1.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, eps1e-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十有八九是梯度爆炸或者除零。排查顺序建议这样先看学习率是不是太大试试降到原来的十分之一。检查损失函数里有没有log(0)或除以零的操作加个epsilon保护。打印梯度范数确认是不是爆炸了是的话加梯度裁剪。检查数据里有没有 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 的训练模式这样每次前向传播的结果都是确定的梯度也应该是确定的。在这个基础上做数值梯度检查最容易定位问题。等确认梯度没问题了再逐步恢复正常的训练配置。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑