
Autograd & Computational Graph —— 自动微分与计算图
系统讲解自动微分与计算图的底层原理:从计算图的有向无环图(DAG)结构出发,剖析链式法则是反向传播的“数学引擎”,阐明雅可比矩阵与海森矩阵的几何意义与计算方式,深入解析PyTorch中retain_graph与create_graph的区别与应用场景,对比静态图与动态图的底层哲学差异,并通过手写微型自动微分框架揭示Autograd的本质实现。
阅读文章ZHY's Blog
A UNIVERSE OF IDEAS · BY ZHANG HAOYI
让好奇心 点亮知识宇宙
在代码、模型与思想之间自由漫游。这里持续记录人工智能、机器学习、软件工程与成长实践,让每次阅读都成为一次新的发现。
ARTICLE NOTE
在前四篇文章中,我们完成了深度学习大厦的完整地基铺设:张量提供了数据容器,概率论定义了优化目标(MLE),激活函数与初始化保障了梯度在深层中的流动,自动微分与计算图则揭示了框架计算梯度的底层机制。现在,所有铺垫指向同一个终点——如何将这些知识系统性地应用于一个完整的神经网络?
本篇博客是整个系列的第一个“集大成者”。我们从全连接层的矩阵前向传播出发,但重点并非简单的线性变换,而是以计算图为工具,逐层手动推导一个3层MLP的完整反向传播过程。原本抽象的 “节点-边”结构,在这里具体化为误差信号 δ 从输出层向输入层的逐层回传 —— 权重梯度的本质,不过是局部误差与输入的乘积。我们将提炼出三个贯穿所有深度网络的核心公式:误差回传、权重梯度与偏置梯度,它们构成了从LeNet到Transformer所有模型参数更新的共同底层逻辑。
此外,我们还将讨论通用近似定理(UAT) 的“砖块构造”直觉——为何单隐藏层在理论上已足够,但深度在实践中更高效;并深入Batch Normalization的训练-推理双模机制,揭示其滑动平均(EMA)更新全局统计量的工程细节。
值得注意的是,本篇推导出的参数梯度,正是后续梯度下降优化器中SGD、Adam等算法所“消费”的燃料。现在,请带着“误差如何反向流动”的疑问进入正文——当您亲手推完这3层网络的梯度公式后,您将真正拥有“从零搭建并训练神经网络”的底层自信。
一个全连接层(Fully Connected Layer,也称稠密层或Affine层)接收输入向量,通过线性变换加偏置后输出。设输入为 x∈Rd,则该层的输出为:
z=Wx+b其中 W∈Rh×d 是权重矩阵,b∈Rh 是偏置向量。经过激活函数 ϕ(如ReLU、Sigmoid等)后得到该层的输出:
a=ϕ(z)这里的 a 即为下一层的输入。
实际训练中我们一次处理一个mini-batch,设批量大小为 m,输入矩阵为 X∈Rm×d(每行一个样本),则前向传播为:
Z=XW⊤+b注意这里 W⊤ 的维度是 h×d,Z 的维度是 m×h,每一行对应一个样本在该层的输出。
💡 维度检验小技巧:矩阵乘法中,中间维度必须匹配。Xm×d×Wh×d⊤ 的转置是 Wd×h,所以 XW⊤ 得到 m×h,正好是每个样本的 h 维输出。偏置 b 通过广播(broadcasting)加到每一行上。
1import numpy as np2
3class FullyConnectedLayer:4 def __init__(self, input_dim, output_dim):5 # 使用Xavier初始化6 self.W = np.random.randn(output_dim, input_dim) * np.sqrt(2.0 / input_dim)7 self.b = np.zeros(output_dim)8
9 def forward(self, X):10 """11 X: (batch_size, input_dim)12 返回: (batch_size, output_dim)13 """14 self.X = X # 保存用于反向传播15 self.Z = X @ self.W.T + self.b # (m, h)16 return self.Z计算图是一种有向无环图(DAG) ,用于可视化运算符和变量在计算中的依赖关系。正向传播沿着图从输入到输出顺序计算,而反向传播则按相反的顺序(从输出层到输入层) 计算和存储中间变量及参数的梯度。
反向传播的本质就是链式法则(Chain Rule) 的反复应用。让我们用一个具体的3层MLP来走一遍完整流程。
考虑一个3层MLP(输入层→隐藏层1→隐藏层2→输出层):
为简化推导,我们先考虑单个样本 x∈Rd,批量版本可以看作单样本版本的向量化堆叠。
前向传播的数学表达:
z(1)a(1)z(2)a(2)z(3)y^=W(1)x+b(1)=ϕ1(z(1))=W(2)a(1)+b(2)=ϕ2(z(2))=W(3)a(2)+b(3)=z(3)(输出层无激活)损失函数采用均方误差(MSE):L=21∥y^−y∥2,其中 y 是真实标签。
1x → [W¹,b¹] → z¹ → [φ₁] → a¹ → [W²,b²] → z² → [φ₂] → a² → [W³,b³] → z³ → [Loss] → L2 ↑ ↑ ↑3 (参数) (参数) (参数)箭头方向代表数据依赖关系:L 依赖于 z(3),z(3) 依赖于 W(3) 和 a(2),依此类推。
反向传播的核心任务是计算损失 L 对每个参数(W(l) 和 b(l))的梯度。我们从输出层开始,逐层向前推导。
记 误差项 δ(3)=∂z(3)∂L=y^−y。
根据链式法则:
∂W(3)∂L=∂z(3)∂L⋅∂W(3)∂z(3)=δ(3)(a(2))⊤∂b(3)∂L=δ(3)这里 δ(3) 是 q×1 的列向量,(a(2))⊤ 是 1×h2 的行向量,外积得到 q×h2 的矩阵。
其中 ⊙ 表示逐元素相乘(Hadamard积)。记 δ(2)=∂z(2)∂L。
观察上述推导,我们可以提炼出三层MLP反向传播的三个核心公式:
(1)误差回传公式(从第 l+1 层传到第 l 层):
δ(l)=((W(l+1))⊤δ(l+1))⊙ϕl′(z(l))(2)权重梯度公式:
∂W(l)∂L=δ(l)(a(l−1))⊤(3)偏置梯度公式:
∂b(l)∂L=δ(l)其中 a(0)=x。
🔑 核心洞察:反向传播的本质就是将误差从输出层逐层”分发”回每一层。每一层收到的”误差信号” δ(l) 包含了损失对该层线性变换前输入的敏感度,再结合该层的激活函数导数和输入,就能算出参数梯度。
对于batch size为 m 的情况,上述所有向量变为矩阵,逐元素乘法变为逐元素乘法(仍为Hadamard积),公式形式不变:
A(0)=X∈Rm×dZ(l)=A(l−1)(W(l))⊤+b(l),A(l)=ϕl(Z(l))误差回传:
Δ(l)=(Δ(l+1)W(l+1))⊙ϕl′(Z(l))权重梯度(注意除以batch size取平均):
∂W(l)∂L=m1(Δ(l))⊤A(l−1)1import numpy as np2
3class ThreeLayerMLP:4 def __init__(self, d, h1, h2, q):5 # 初始化权重和偏置6 self.W1 = np.random.randn(h1, d) * np.sqrt(2.0 / d)7 self.b1 = np.zeros(h1)8 self.W2 = np.random.randn(h2, h1) * np.sqrt(2.0 / h1)9 self.b2 = np.zeros(h2)10 self.W3 = np.random.randn(q, h2) * np.sqrt(2.0 / h2)11 self.b3 = np.zeros(q)12
13 def forward(self, X):14 """前向传播,保存中间变量"""15 self.X = X16 self.Z1 = X @ self.W1.T + self.b117 self.A1 = np.maximum(0, self.Z1) # ReLU18 self.Z2 = self.A1 @ self.W2.T + self.b219 self.A2 = np.maximum(0, self.Z2) # ReLU20 self.Z3 = self.A2 @ self.W3.T + self.b321 return self.Z322
23 def backward(self, y, learning_rate=0.01):24 """反向传播,计算梯度并更新参数"""25 m = self.X.shape[0]26
27 # 输出层误差28 delta3 = self.Z3 - y # (m, q)29
30 # 输出层参数梯度31 dW3 = delta3.T @ self.A2 / m # (q, h2)32 db3 = np.mean(delta3, axis=0) # (q,)33
34 # 回传到第二隐藏层35 delta2 = (delta3 @ self.W3) * (self.Z2 > 0).astype(float) # (m, h2)36 dW2 = delta2.T @ self.A1 / m # (h2, h1)37 db2 = np.mean(delta2, axis=0) # (h2,)38
39 # 回传到第一隐藏层40 delta1 = (delta2 @ self.W2) * (self.Z1 > 0).astype(float) # (m, h1)41 dW1 = delta1.T @ self.X / m # (h1, d)42 db1 = np.mean(delta1, axis=0) # (h1,)43
44 # SGD更新45 self.W3 -= learning_rate * dW346 self.b3 -= learning_rate * db347 self.W2 -= learning_rate * dW248 self.b2 -= learning_rate * db249 self.W1 -= learning_rate * dW150 self.b1 -= learning_rate * db151
52 return dW1, db1, dW2, db2, dW3, db3通用近似定理(UAT)是神经网络理论中最令人惊叹的结果之一。它最经典的版本(Cybenko, 1989;Hornik et al., 1989)指出:
任何定义在紧致集上的连续函数,都可以被一个具有单隐藏层(足够宽)的前馈神经网络以任意精度近似。
换句话说,只要隐藏层有足够多的神经元,一个简单的MLP就能逼近任意复杂的函数。
理解UAT最直观的方式是**“砖块构造法”(bump construction)** 。
想象一下:两个Sigmoid神经元可以组合成一个”凸起”函数(bump function) ——这个函数只在某个小区间内非零,其他地方几乎为零。具体来说:
现在,任意连续函数都可以看作无数个这样的”凸起”的叠加——这正是傅里叶分析或勒贝格积分的基本思想:用矩形条(或更平滑的基函数)去逼近任意函数。
只要隐藏层有足够多的神经元,每个神经元对可以生成一个”凸起”,所有凸起叠加起来就逼近了目标函数。
⚠️ 重要提醒:UAT是存在性定理,它告诉我们”存在这样一个网络”,但没有告诉我们如何找到它(即如何训练)。而且,虽然单隐藏层在理论上足够,但实践中深度网络往往比宽度网络更高效——用2k层比用2k个神经元的单层要高效得多。
想象你要用乐高积木拼出一个复杂的曲线:
UAT告诉我们:只要有足够多的基本积木(神经元),总能拼出任何形状。但实际操作中,深度网络通过层次化组合能用更少的参数实现更复杂的函数。
深层神经网络在训练时面临一个棘手的问题:每一层的输入分布随着前一层参数的变化而变化——这被称为”内部协变量偏移”(Internal Covariate Shift)。Batch Normalization(BN)通过对每一层的输入进行归一化来解决这个问题,使每层的输入保持近似零均值和单位方差。
对于某个全连接层的输入 x(一个mini-batch,大小为 m),BN执行以下变换:
Step 1:计算batch的均值和方差
μB=m1i=1∑mxi,σB2=m1i=1∑m(xi−μB)2Step 2:归一化
x^i=σB2+ϵxi−μB其中 ϵ 是一个很小的常数(如 10−5),防止除零。
Step 3:缩放和平移(可学习参数)
yi=γx^i+β这里的 γ 和 β 是可训练参数,用于恢复模型的表达能力——如果标准化破坏了有用的特征分布,网络可以学习如何”撤销”部分标准化效果。
这里有一个关键的细节:在训练时,BN使用的是当前mini-batch的均值和方差进行归一化。但到了推理(测试)阶段,我们面对的往往只有一个样本(或很小的batch),无法计算有统计意义的均值和方差。
因此,BN在训练过程中需要维护全局统计量——即整个训练集的均值和方差的估计值。这个维护是通过指数加权移动平均(Exponential Moving Average, EMA) 实现的:
μglobal=momentum⋅μglobal+(1−momentum)⋅μBσglobal2=momentum⋅σglobal2+(1−momentum)⋅σB2其中:
📌 momentum的直觉:momentum越大,全局统计量更新越平滑,对历史数据的”记忆”越长(0.99相当于平均过去100个batch);momentum越小,更新越快,对当前batch更敏感(0.9相当于平均过去10个batch)。
| 阶段 | 使用的均值和方差 | 是否更新全局统计量 |
|---|---|---|
| 训练 | 当前mini-batch的 μB,σB2 | ✅ 更新 μglobal,σglobal2 |
| 推理 | 累积的 μglobal,σglobal2 | ❌ 不更新 |
在推理模式下(如PyTorch中调用model.eval()),BN层冻结全局统计量,直接用训练时累积的均值和方差进行归一化。这保证了推理时无论输入batch大小如何,都能得到稳定一致的输出。
1class BatchNorm:2 def __init__(self, num_features, momentum=0.9, eps=1e-5):3 self.num_features = num_features4 self.momentum = momentum5 self.eps = eps6
7 # 可训练参数8 self.gamma = np.ones(num_features)9 self.beta = np.zeros(num_features)10
11 # 全局统计量(非训练参数,通过滑动平均更新)12 self.running_mean = np.zeros(num_features)13 self.running_var = np.ones(num_features)14
15 # 保存中间变量用于反向传播16 self.x_centered = None17 self.std_inv = None18 self.x_norm = None19
20 def forward(self, X, training=True):21 """22 X: (batch_size, num_features)23 training: True表示训练模式,False表示推理模式24 """25 if training:26 # 计算当前batch的统计量27 batch_mean = np.mean(X, axis=0) # (num_features,)28 batch_var = np.var(X, axis=0) # (num_features,)29
30 # 归一化31 self.x_centered = X - batch_mean32 self.std_inv = 1.0 / np.sqrt(batch_var + self.eps)33 self.x_norm = self.x_centered * self.std_inv34
35 # 滑动平均更新全局统计量36 self.running_mean = self.momentum * self.running_mean + \37 (1 - self.momentum) * batch_mean38 self.running_var = self.momentum * self.running_var + \39 (1 - self.momentum) * batch_var40 else:41 # 推理模式:使用累积的全局统计量42 self.x_norm = (X - self.running_mean) / np.sqrt(self.running_var + self.eps)43
44 # 缩放和平移45 out = self.gamma * self.x_norm + self.beta46 return out本文从矩阵前向传播出发,逐层推导了3层MLP的反向传播过程,核心要点可以概括为:
理解这些原理,你就掌握了深度学习最核心的”底层逻辑”。下一篇文章,我们将在此基础上探讨更复杂的网络结构——卷积神经网络(CNN)的反向传播。
延伸阅读:
按顺序完成这组文章,循序渐进地掌握主题
发现错误、内容过时或有改进想法?欢迎告诉我
根据本文分类与标签,为你推荐可能感兴趣的内容

系统讲解自动微分与计算图的底层原理:从计算图的有向无环图(DAG)结构出发,剖析链式法则是反向传播的“数学引擎”,阐明雅可比矩阵与海森矩阵的几何意义与计算方式,深入解析PyTorch中retain_graph与create_graph的区别与应用场景,对比静态图与动态图的底层哲学差异,并通过手写微型自动微分框架揭示Autograd的本质实现。
阅读文章
系统讲解卷积神经网络(CNN)的四大核心操作:从严格卷积与互相关的数学区别出发,推导感受野的递推计算公式,剖析空洞卷积如何在不增加参数的前提下扩大感受野,详解Max Pooling与Average Pooling的反向传播机制,以及1×1卷积的跨通道信息整合、降维/升维与增加非线性的三大作用。
阅读文章
系统讲解LSTM与GRU的门控机制设计哲学与数学原理。从LSTM的“双轨”架构(细胞状态+隐藏状态)与三门结构(遗忘门、输入门、输出门)出发,推导细胞状态更新公式如何通过按元素加法路径缓解梯度消失,并深入剖析GRU的两大简化及其参数量优势。附带讨论双向LSTM与深层堆叠LSTM的应用场景。
阅读文章请使用微信扫描二维码分享
当前文章会保持在原页面