手写计算图,彻底搞懂反向传播与自动微分原理

发布时间:2026/10/22 22:09:32
手写计算图,彻底搞懂反向传播与自动微分原理 1. 计算图理解反向传播的直观钥匙做深度学习这些年我越来越觉得最值得花时间啃透的两个基础概念一个是误差反向传播法Backpropagation另一个就是它背后那张决定一切的“计算图”Computational Graph。不管你自己调网络改结构还是半夜排查梯度不下降、loss变成NaN这种诡异问题只要真正吃透这两样东西定位问题的时间起码缩短一半。这篇文章就围绕“误差反向传播法(计算图)”这个主题把从正向传播到反向传播的完整链路拆开讲包括计算图节点怎么设计、梯度怎么一层层回流、手写一个迷你实现做数值梯度验证以及几个我在实际训练中踩过的典型坑位。现在主流深度学习框架早已把反向传播封装好了你写几行代码调用一下backward梯度就自动算好参数也能自动更新。但这种封装是一把双刃剑用熟了会觉得很方便可一旦遇到梯度不更新、loss不降、loss直接变NaN这类现象如果不懂底层原理根本不知道从哪下手。这篇文章适合两类人看一类是刚接触深度学习、被各种链式求导和矩阵维度换算劝退的初学者另一类是已经用框架写过不少模型、但一直没搞懂自动微分内部逻辑的开发者。读完你会知道一个可以用在简单网络上的反向传播实现是怎么写的同时获得对框架自动求导机制更落地的直觉。1.1 为什么非用计算图不可先想一个问题神经网络里的函数本质上是什么是大量线性变换和激活函数叠在一起的长复合函数。一个几层网络展开后的表达式可以长得像灾难现场如果这个网络里还有多个分支、共享参数、残差连接之类的结构直接按数学公式手推梯度几乎是不可能的任务。计算图的出现就是为了解决这个痛点。它把复杂的数学表达式拆成一个一个基础运算节点然后用有向图把这些节点连起来。图中每个节点代表一个运算每条边代表数据流动。这样做有两个直接好处第一每个节点只做局部计算输入是啥就输出啥完全不用关心整条链路第二正因为每个节点是局部的梯度计算也可以局部化——每个节点只需要知道如何计算自己输出相对于输入的局部导数然后借助链式法则把梯度从后往前传。从工程角度看这种设计还天然适合模块化。你想加一种新运算只需要在框架里注册一个节点定义它的正向计算规则和反向求导规则剩下的事情框架自动搞定。现代深度学习框架的自动微分功能其底层几乎都是围绕这张计算图做的只是在不同框架里它可能换了个名字有的叫“反向图”有的叫“梯度图”但核心逻辑一致。1.2 计算图里的基本构成我可以把计算图想象成一条生产流水线。每个工位就是计算图上的一个节点工位干的事很简单接过上游传下来的半成品张量做一道固定工序再把新半成品传给下游。整条流水线有两个极端位置原料入口和成品出口。在深度学习模型里原料入口是输入数据和模型参数成品出口是损失函数计算出来的loss值。具体到节点常见的有这么几类算术节点加法、减法、乘法、除法线性代数节点矩阵乘法、转置、求和激活函数节点ReLU、Sigmoid、Tanh损失函数节点均方误差、交叉熵形状操作节点reshape、transpose这些节点在正向传播时做的事情并不难难的是反向传播时每个节点都要知道自己怎么对上游梯度做处理。这就像流水线工位上的工人生产时负责加工零件遇到质量问题追查时要能清楚地说出自己这道工序该承担多少责任。2. 正向传播数据流动的完整旅程正向传播的方向是从输入到输出也叫前向计算。它的规则只有一个按依赖顺序依次算出每个节点的输出值。听起来平淡无奇但在计算图的语境里有一个很容易忽略的关键操作——缓存。我们拿一个生活中的例子来模拟一下。假设你要预测一家街角咖啡店的日销量只考虑两个因素当天的天气评分x10分雨天、10分晴天和当日优惠力度x20分无优惠、10分大促。我们用最简单的神经网络式子来建模z w1 * x1 w2 * x2 b a sigmoid(z)其中w1、w2是权重b是偏置。假设某天晴天且有小幅优惠x18x25初始权重随便给一组数据比如w10.42w2-0.18b0.1。线性部分算出z 0.42×8 (-0.18)×5 0.1 3.36 - 0.9 0.1 2.56。然后用sigmoid函数压缩到0到1之间a≈0.928。2.1 正向传播时为什么必须缓存中间结果上面这个例子正向传播依次得到了z2.56和a≈0.928。但请注意在反向传播时我们计算sigmoid节点的局部梯度需要用到a的值准确说是a×(1-a)计算线性节点的梯度需要用到x1、x2的值。也就是说正向传播算出来的中间结果反向传播阶段还得再用一次。这就引出了正向传播阶段一个极其重要的细节每个节点必须把自己的输出值缓存下来。如果不缓存反向传播时就要把所有中间结果重新算一遍计算量直接翻倍。实际深度学习框架正是这样做的——你在显存里看到的那些“激活值”“中间张量”很大一部分就是为反向传播准备的缓存。所以正向传播不只是一次干净利落的计算它还承担着“记忆”职责。网络越深中间缓存越多显存占用越高。这也是为什么你在推理阶段可以大幅降低显存使用——因为推理不需要反向传播自然不需要保留中间缓存。有些框架甚至提供“推理模式”或者“无梯度模式”就是为了省掉这部分开销。2.2 局部计算原则计算图正向传播的另一条核心准则是局部计算。每个节点只关心自己的输入是什么然后按照固定规则算出输出完全不关心上游的数据从哪来也不关心下游的数据被拿去做什么。这个原则的价值是巨大的。想想看如果每个节点都要了解整个网络的结构才能算那复杂度会随网络规模指数级增长。而局部计算保证了计算图可以一节一节地拼接起来。你写好一个“加法节点”它放在网络的第2层没问题放在第100层也没问题因为它对上层唯一的要求就是“输入是两个张量”。到这一步你也可以理解为什么现在主流的网络模块化设计这么受欢迎。你把各种节点组装成一个大模块模块里再嵌套模块计算图依然只是多了一些子图而已正向传播和反向传播的规则完全不需要改变。3. 反向传播链式法则与逐层回传反向传播是整个训练过程的灵魂。如果说正向传播是“生产”反向传播就是“追责”。追责的方向是从成品往原料方向走目标是搞明白每个参数对最终损失函数的影响到底有多大也就是梯度。3.1 链式法则反向传播的数学根基链式法则是微积分里的基础知识但在计算图的语境下我想换个说法重新讲。假设最终输出L我们想知道某个中间变量z变化一点点L会变化多少这其实就是L对z的偏导数∂L/∂z。如果z是由x通过函数zf(x)得到的那么在x位置做一个微小扰动z的扰动是f(x)乘以这个扰动L的扰动则是∂L/∂z乘以z的扰动。合并起来就得到∂L/∂x ∂L/∂z × ∂z/∂x这个公式就是链式法则的朴素形态。放回计算图里它传达了一个重要的操作规则反向传播时梯度每经过一个节点就把上游传来的梯度乘上这个节点的局部导数。生活类比一下就像退货流程。某品牌的产品出厂后发现问题从总代理开始追查一级一级往上游追溯。每一级经销商在接收退货时都要确认自己在运输环节中造成了多大损坏然后只把自己造成的损坏比例乘到原来的问题量上传给上一级。如果某一级确认自己没造成额外问题相当于局部导数为1就把问题量原样传上去如果自己造成了双倍问题相当于局部导数为2就放大后再传上去。3.2 常见节点的反向传播规则为了实际操作我们把最常见的节点反向规则整理成一张表。这张表值得记住因为它基本覆盖了90%的手写实现场景节点类型正向传播输出反向传播时对输入的处理加法节点 y a b上游梯度直接分发给a和b每个输入收到的梯度不变乘法节点 y a × b上游梯度分别乘以对方的输入值a收到 grad × bb收到 grad × a矩阵乘法 y a ba的梯度 grad b.Tb的梯度 a.T gradSigmoid节点 y sigmoid(x)上游梯度乘以 y(1-y)传给xReLU节点 y max(0, x)x0时梯度原样传x0时梯度为0传给x加法节点和乘法节点是两条最基础的直觉规则我建议你务必记牢加法就是“梯度分流”上游给了1的梯度两个输入各拿1互不打扰。 乘法就是“交叉传递”上游给了grada拿grad×bb拿grad×a相当于对方的数值越大自己分摊的责任就越重。为什么乘法要交叉乘对方的输入值从导数的角度看∂y/∂a b∂y/∂b a所以梯度自然会乘对方的输入值。你可以直接把它当结论记住用多了自然有手感。3.3 咖啡店例子的完整反向传播接着用咖啡店预测的例子把反向传播完整跑一遍。假设损失函数用最简单的均方误差真实销量归一化后是0.98预测值a≈0.928那么loss (a - 0.98)² / 2。为什么要除以2因为求导后可以消掉手算更清爽。第一步算loss对a的梯度。∂loss/∂a a - 0.98 -0.052。这个梯度记为上游梯度。第二步经过sigmoid节点。a sigmoid(z)sigmoid的局部导数为a(1-a) 0.928×0.072 ≈ 0.0668所以∂loss/∂z -0.052 × 0.0668 ≈ -0.00347。第三步经过线性节点z w1×x1 w2×x2 b。这时三个东西的梯度分别为 ∂loss/∂w1 ∂loss/∂z × x1 -0.00347 × 8 -0.0278 ∂loss/∂w2 ∂loss/∂z × x2 -0.00347 × 5 -0.0174 ∂loss/∂b ∂loss/∂z × 1 -0.00347这些负数梯度告诉我们想让loss变小w1、w2、b都应该往正方向调大一点。于是参数更新就变成新值 旧值 - 学习率 × 梯度。如果学习率设为1那么w1更新为0.42 - (-0.0278) 0.4478w2更新为-0.18 - (-0.0174) -0.1626b更新为0.1 - (-0.00347) 0.10347。这样一圈走下来参数确实在往loss减小的方向移动。虽然步幅不一定最优但方向是对的。整套逻辑在多层网络中一模一样只是链条更长、节点更多核心法则从未改变。4. 手写一个迷你计算图从零实现反向传播概念说完了光说不练等于白看。这一节我们手写一个极简的计算图框架它能完成最基本的正向传播、反向传播和参数更新。虽然简陋但五脏俱全你可以在这个基础上继续扩展。4.1 极简Tensor类的设计我设计的类叫Tensor它同时保存正向传播的value、反向传播时累积的grad、子节点children和运算类型op。重载加法和乘法运算符这样写网络时可以像写普通代数式一样直观。import numpy as np class Tensor: 极简计算图节点 value: 正向传播计算的数值 grad: 反向传播累积的梯度 children: 参与当前运算的子节点 op: 当前节点执行的运算类型 def __init__(self, value, children(), op): self.value np.asarray(value, dtypefloat) self.children children self.op op self.grad 0.0 # 正向运算与构图 def __add__(self, other): other as_tensor(other) return Tensor(self.value other.value, (self, other), add) def __mul__(self, other): other as_tensor(other) return Tensor(self.value * other.value, (self, other), mul) def dot(self, other): other as_tensor(other) return Tensor(np.dot(self.value, other.value), (self, other), dot) def sigmoid(self): return Tensor(1.0 / (1.0 np.exp(-self.value)), (self,), sigmoid) # 反向传播 def backward(self, grad1.0): # 先做拓扑排序保证从输出一路排到输入 nodes [] visited set() def dfs(node): if id(node) in visited: return visited.add(id(node)) for child in node.children: dfs(child) nodes.append(node) dfs(self) # 梯度清零 for node in nodes: node.grad 0.0 self.grad grad # 按拓扑逆序传播梯度 for node in reversed(nodes): if node.op add: for child in node.children: child.grad node.grad elif node.op mul: a, b node.children a.grad node.grad * b.value b.grad node.grad * a.value elif node.op dot: a, b node.children a.grad node.grad.dot(b.value.T) b.grad a.value.T.dot(node.grad) elif node.op sigmoid: a node.children[0] y node.value a.grad node.grad * y * (1.0 - y) def as_tensor(x): return x if isinstance(x, Tensor) else Tensor(x)这套实现的思路是正向运算时顺手把父子关系记录下来反向传播时先用拓扑排序确定传播顺序再逐节点按规则把梯度传给父节点。这里有个细节很重要梯度用的是而不是。为什么因为一个参数节点可能被多个下游分支共用比如同一个权重矩阵被前后两个地方引用反向传播时两个分支传回来的梯度要相加。如果用等号赋值后面的梯度会覆盖前面的梯度这种bug非常隐蔽。4.2 数值梯度验证判断反向传播算得对不对手写反向传播最怕什么怕算法写错了自己还不知道。好在有一个非常可靠的验证手段数值微分。它的原理很简单就是直接从导数的定义出发f(x) ≈ (f(xε) - f(x-ε)) / (2ε)这个公式叫中心差分误差是O(ε²)比单侧差分精度高得多。对每一个参数依次做一个微小扰动重新跑一遍正向传播把损失函数值的变化量除以扰动大小就得到数值梯度。如果手写反向传播得到的梯度与数值梯度处处接近基本可以判定代码正确。下面这个函数可以对比任意参数的数值梯度与反向传播梯度def numerical_gradient(f, x, eps1e-5): f是只接受一个参数的函数x是需要求梯度的变量 grad np.zeros_like(np.asarray(x, dtypefloat)) it np.nditer(x, flags[multi_index]) while not it.finished: idx it.multi_index old_val it[0] x np.asarray(x, dtypefloat) x[idx] old_val eps f_plus f(x) x[idx] old_val - eps f_minus f(x) grad[idx] (f_plus - f_minus) / (2 * eps) x[idx] old_val it.iternext() return grad其实可以不用这么复杂。我们把上一节的咖啡店例子写成一个函数然后分别用Tensor反向传播和数值微分算梯度# 定义正向计算过程 def forward(w1, w2, b, x18.0, x25.0, y_true0.98): z w1 * x1 w2 * x2 b a 1.0 / (1.0 np.exp(-z)) return 0.5 * (a - y_true) ** 2 # 用Tensor类计算反向传播梯度 w1 Tensor(0.42) w2 Tensor(-0.18) b Tensor(0.1) z w1 * 8.0 w2 * 5.0 b a z.sigmoid() loss 0.5 * (a - 0.98) ** 2 loss.backward() print(反向传播梯度 w1:, w1.grad) print(反向传播梯度 w2:, w2.grad) print(反向传播梯度 b:, b.grad) # 用数值梯度验证 eps 1e-5 ng_w1 (forward(0.42 eps, -0.18, 0.1) - forward(0.42 - eps, -0.18, 0.1)) / (2 * eps) ng_w2 (forward(0.42, -0.18 eps, 0.1) - forward(0.42, -0.18 - eps, 0.1)) / (2 * eps) ng_b (forward(0.42, -0.18, 0.1 eps) - forward(0.42, -0.18, 0.1 - eps)) / (2 * eps) print(数值梯度 w1:, ng_w1) print(数值梯度 w2:, ng_w2) print(数值梯度 b:, ng_b)正常情况下两组数字在小数点后4位内应该一致。我在实际验证时踩过的坑是差分会丢掉前导位如果损失值本身非常大比如几千量级中心差分会因为浮点精度问题产生偏差。遇到这种情况把输入和损失都做归一化再验证。4.3 用迷你框架训练两参数线性模型有了梯度更新参数就顺理成章了。下面用一个非常小的线性模型在合成数据集上跑几十轮梯度下降验证整套代码可以端到端工作np.random.seed(42) # 生成100个样本特征是2维标签由真实权重决定 x_data np.random.randn(100, 2) true_w np.array([2.0, -1.0]) y_data x_data true_w 0.1 * np.random.randn(100) # 参数初始化 w Tensor(np.array([0.5, -0.5])) b Tensor(0.0) lr 0.1 for epoch in range(50): # 正向传播线性模型 pred x_data.dot(w) b diff pred.value - y_data loss np.mean(diff ** 2) # 计算梯度loss mean((pred - y)^2) delta diff / y_data.shape[0] # pred.grad 2 * delta但上面直接构造损失两步合在一起 grad_pred 2.0 * delta # 这里用Tensor自动反向传播 pred.backward(grad_pred) # 更新参数 w.value - lr * w.grad b.value - lr * b.grad if epoch % 10 0: print(fepoch {epoch}, loss {loss:.6f}, fw.grad {w.grad})这个训练循环每轮结束后要特别注意一件事梯度是需要清零的因为反向传播的规则会把历史梯度积累下来。我的代码里在每次backward时自己做了清零这在框架层面尤为重要。如果用真正的深度学习框架通常要显式调用optimizer.zero_grad()来清空梯度忘记执行这一步是训练前期的经典错误最终表现就是loss一顿乱跳不按预期下降。5. 反向传播训练中最常见的坑与排查实录写代码跑通一次反向传播不难难的是在你的模型开始变得复杂、数据变得不听话之后依然能够稳住训练过程。下面这条几个问题是我在调试中反复遇到的。5.1 梯度消失与梯度爆炸反向传播是一层一层往回传的。如果每一层的局部导数都小于1经过几十层之后连乘效应会让梯度指数级衰减到接近0这就是梯度消失。反过来如果某些层的局部导数远大于1梯度会指数级放大很快变成无穷大这就是梯度爆炸。症状最容易辨认loss变成NaN或者权重数值突然炸到极大。排查思路是看梯度的数值分布。在每个batch之后打印各层梯度的均值/标准差如果浅层梯度比深层梯度小好几个数量级大概率就是梯度消失如果某些层梯度巨大可能就是梯度爆炸。我在实际项目中常用的手段有几个。一个是换激活函数比如用ReLU替代Sigmoid作为隐藏层激活因为ReLU的正向导数恒为1正区间不会进一步放大或缩小梯度。另一个是加BatchNorm层它把每层输入拉回到标准分布能让梯度保持合理范围。还有一个是梯度裁剪当梯度的模超过一定阈值时按比例缩放虽然治标不治本但在训练初期可以防止训练直接崩盘。5.2 数值梯度验证失败时先查这几件事如果数值梯度和反向传播梯度对不上先别急着怀疑理论错。绝大多数情况是以下某一处出了问题第一epsilon选得不对。中心差分用1e-5通常合适但如果网络里的函数在某个点附近急剧变化就需要更小的epsilon同时要注意浮点精度带来的舍入误差。第二存在不可导点。ReLU在0处不可导如果某个样本恰好让某个神经元输入为0数值梯度给出的是左右导数的差商反向传播可能直接采用0或1两边不一致是正常的。第三梯度忘记清零。如果把多个batch的梯度累加后再比较数值自然对不上。第四某个共享参数的梯度被覆盖。在实现中使用而不是这个坑我已经在代码注释里特别标出。从这个角度来看数值梯度验证不只是写框架时才需要的工具。你在正式框架里训练模型时如果怀疑某个自定义算子写错了同样可以先把参数固定用数值梯度的方式验证它的backward实现是否正确。我见过不少复杂模型的训练不收敛最后定位到是某个自定义Op的梯度写反了这类问题不靠数值梯度排查会非常耗时。5.3 显存爆满的元凶中间缓存前面提过正向传播要缓存所有中间节点值供反向传播使用。模型越深、批量越大、特征越多缓存占用越是直线上升。很多人只关注参数量却忘了激活值缓存这一大头。常见的省显存手段包括减小batch size、减少网络的隐藏层维度、在不需要梯度的代码段用no_grad模式。还有一个技术点是梯度检查点它不在正向传播时缓存所有中间结果而是在反向传播时重新计算部分节点牺牲时间换显存。入门阶段我建议你先不要优化显存保证代码能跑通才是第一位。但随着网络加深你迟早会遇到显存瓶颈到时再回头看这节内容就会有切身体会。5.4 学习率给错了梯度再准也没用有时候反向传播算出的梯度完全正确但训练就是不收敛。这个问题最常出在学习率上。学习率太大参数在最优解附近震荡loss表现为忽高忽低学习率太小参数更新极慢loss看起来像一条水平直线半天不降。判断办法其实很简单打印头几个batch的loss变化。如果loss一开始就飙升大概率学习率过大如果loss几乎不动可以尝试把学习率放大10倍看看。我在调参时喜欢从1e-3开始不行就按10倍步长上下试探找到合适量级之后再微调。这个习惯虽然朴素但在初期阶段远比花哨的自适应学习率策略更管用。5.5 参数初始化的“隐形坑”用同一个激活函数参数初始化方式不同训练难度完全不同。如果所有参数都初始化为0那么同一层的每个神经元接收完全相同的输入和梯度它们会始终保持对称不管训练多少轮都相当于一个神经元在干活。这就是神经元的对称性问题。另一个坑是初始值范围太大会导致激活值饱和。比如用Sigmoid时如果初始化权重过大算出来的z会落在非常靠近0或1的饱和区梯度被压到非常小训练半天权重基本不动。所以初始化策略的选取要和激活函数搭配。常见的做法是主张使用He初始化搭配ReLU或者Xavier初始化搭配Tanh。6. 计算图在现代框架中的进阶形态理解了手写计算图之后再回头看主流深度学习框架你会发现它们只是把同一件事做得更工程化、更高效。6.1 动态图与静态图动态图的特点是边构建边执行正向传播的每一行代码都在实时构建计算图一有结果就立即执行。这种模式非常灵活调试的时候可以用print、断点、if分支、for循环符合程序员直觉。主流的新一代框架大多以动态图为主。静态图则是先完整构建一张计算图然后再启动执行。它的好处是框架可以做全图优化比如合并冗余算子、调整执行顺序、减少中间缓存的分配推理和部署时性能更优。缺点也明显调试困难很多控制流要写成框架特定的语法不适合快速原型验证。现在不少框架其实已经把两者融合了先用动态图方式做实验研究和调试再通过某种机制把模型导出成静态图或编译后的表达用于部署和服务。对使用者来说理解背后都是同一张计算图就不会觉得这些模式切换是玄学。6.2 自动微分的三层境界数值微分最直观但速度慢、精度受epsilon影响。符号微分可以推导出精确公式但对深层网络的表达式爆炸完全无能为力。反向传播所使用的自动微分技术本质上是在运行过程中累积梯度把复杂求导问题分解为逐节点的局部导数乘法这不仅精度高而且效率跟正向传播同阶。这也是为什么计算图在工程实现中如此重要的原因自动微分依赖计算图计算图提供拓扑结构反向传播按拓扑逆序高效执行。三者是一脉相承的整体。6.3 给学习者的实际操作建议在我带人入门深度学习时通常会提三个建议。第一写一遍小型计算图实现不要求功能完整够用即可这比读十篇理论文章都深刻。第二遇到框架报错时试着把报错信息翻译成“计算图里的哪个节点出了问题”——梯度为None的报错多半是网络断开了一个分支维度报错通常是某个节点的输入形状不满足运算规则。第三养成看框架文档中autograd部分以及自定义函数接口的习惯这能帮你迅速从使用者进阶到理解者。这个角度再看计算图它就不只是一个教学工具而是理解现代深度学习框架如何设计、如何调试、如何部署的一把钥匙。我个人在实际操作中的体会是计算图这个东西越早手写一遍后续学习越顺畅。很多人在框架里“调参侠”当惯了遇到问题只能上网搜索“loss不下降怎么办”但如果你脑子里有计算图的画面你会自然地去查梯度在哪一步变的奇怪、哪个节点的输出异常、哪条链路的传播断了。最后再分享一个小技巧调试复杂模型时把模型拆成几个子图分别用手算或数值梯度验证每个子图的正反向是否正确确认无误再组装回整体。这个习惯帮我省下了大量排查时间希望对你也有用。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询