读完这一课,你将能够
- 从 \(\delta_2=\hat y-y\) 起手,沿计算图反向独立推出两层网络的全部四个梯度 \(\partial L/\partial W_1,b_1,W_2,b_2\),并用形状自检解释每个转置与左右摆放。
- 说出 softmax 的 log-sum-exp 稳定化为什么按行减最大值就不改变结果、又能防止 \(e^z\) 上溢成 nan。
- 用纯 numpy 实现前向、反向与中心差分梯度检查,并判断相对误差 \(<10^{-7}\) 才算反向写对。
- 讲清 batch 维下"权重梯度对 N 平均、偏置沿 axis=0 求和"的来历,并说出漏除 N 或写错求和轴会怎样。
- 用一句话说明
loss.backward()的本质(计算图 + 拓扑逆序 + 局部链式法则 + 梯度累加),并指出 ReLU 掩码如何造成梯度消失。
1. 开场:把九课的工具拼成一台机器
上一课我们把矩阵求导 matrix calculus讲透了:知道了 \(\partial L/\partial W\) 长什么形状、怎么用链式法则一层层往回传。这一课是模块 1 的压轴实战——我们不再做孤立的公式练习,而是把前九课的全部工具(向量、矩阵变换、内积、ReLU、softmax、链式法则、矩阵求导)拼装成一台真正会"学习"的机器:一个两层全连接神经网络 two-layer fully-connected network,并亲手实现它的反向传播 backpropagation。
读完本课,你会从头到尾推导出每一个梯度公式、用 numpy 把它们写成几行代码、用梯度检查 gradient check证明它们一字不差,然后看着 loss 从高到低真实地降下来。这就是 PyTorch 里那句 loss.backward() 背后的全部真相——理解了它,以后遇到 NaN、梯度爆炸、梯度消失,你才知道去哪儿找毛病。
沿用第 9 课的约定:全程使用分母布局 denominator layout,即 \(\partial L/\partial W\) 与 \(W\) 同形;数据矩阵行 = 一个样本。这两条约定决定了下面所有矩阵乘法的摆放顺序,请时刻记住。
2. 网络长什么样:前向传播
我们要搭的网络结构非常标准:输入向量先过一个线性层,激活,再过一个线性层,最后用 softmax 变成概率,用交叉熵衡量它和真实标签差多少。画成计算图就是一条链:
先看单个样本(列向量 \(x\in\mathbb{R}^{D}\))的前向公式。设隐藏层有 \(H\) 个单元、输出 \(C\) 个类别:
\[ \begin{aligned} z_1 &= W_1 x + b_1, & W_1&\in\mathbb{R}^{H\times D},\ b_1\in\mathbb{R}^{H} \quad\text{(第一层线性)}\\ a_1 &= \mathrm{ReLU}(z_1) = \max(0,\,z_1) & &\text{(逐元素激活)}\\ z_2 &= W_2 a_1 + b_2, & W_2&\in\mathbb{R}^{C\times H},\ b_2\in\mathbb{R}^{C} \quad\text{(第二层线性)}\\ \hat{y} &= \mathrm{softmax}(z_2) & &\text{(变成概率分布)}\\ L &= \mathrm{CE}(\hat{y}, y) = -\sum_{c=1}^{C} y_c \log \hat{y}_c & &\text{(交叉熵损失)} \end{aligned} \]这里 \(y\) 是独热向量 one-hot vector:真实类别那一位是 1,其余是 0。所以交叉熵其实只剩一项 \(L=-\log\hat{y}_{\text{正确类}}\)——模型给正确类的概率越高,loss 越小。\(\mathrm{softmax}\) 的第 \(c\) 个分量是
\[ \hat{y}_c = \frac{e^{z_{2,c}}}{\sum_{k=1}^{C} e^{z_{2,k}}}, \]它把任意一组实数"挤"成一个和为 1 的概率分布。
ML 和 ML 的联系
这五行就是几乎所有分类网络的"骨架"。把 \(W_1 x\) 换成卷积,它就是 CNN 的一个 block;把它堆 96 层、把 ReLU 换成 GELU,它就是 GPT 里的 MLP 子层。线性 → 非线性 → 线性 → softmax → 交叉熵这条流水线,你会在后面每一个模型里反复见到。今天把两层的梯度推一遍,后面的"深"网络只是同一个模式重复 \(L\) 次。
3. 数值稳定:softmax 的 log-sum-exp 技巧
在写代码之前必须先排一个雷。softmax 里有 \(e^{z}\),如果某个 \(z\) 比较大(比如 \(z=1000\)),\(e^{1000}\) 会直接上溢 overflow 成 inf,整个计算变成 nan。解决办法是一个简单又精妙的恒等式:分子分母同乘 \(e^{-m}\) 不改变结果,
我们取 \(m=\max_k z_k\)(每一行各自的最大值)。这样减完之后,指数的输入最大是 0,\(e^{0}=1\) 不会溢出;最小的那些变成很负的数,\(e^{-\text{大}}\to 0\) 安全地下溢成 0。这就是log-sum-exp 稳定化。代码里永远写 Z - Z.max(axis=1, keepdims=True),一行都不能省。
易错
减最大值要按行(每个样本各自)减,不是减全局最大值,更不能不减。另外 keepdims=True 不能丢——否则 (N,C) 减 (N,) 会触发错误的广播(broadcast)。求 loss 时也别写 np.log(yhat) 直接喂概率:如果某个 \(\hat y\) 被算成 0,log(0)=-inf。我们加一个极小的 1e-12 兜底。
4. 反向传播:从损失一路倒推回去
现在是本课的心脏。我们要的是 \(\partial L/\partial W_1,\ \partial L/\partial b_1,\ \partial L/\partial W_2,\ \partial L/\partial b_2\)。策略是沿计算图反向走,每经过一个节点,就用上一课的链式法则把梯度往前一站传递。绿色是前向数据流,橙色是反向梯度流:
第一步(最关键):softmax + 交叉熵合并求导
单独对 softmax 求导很麻烦(要算雅可比矩阵),但 softmax 紧接着交叉熵时,会发生一次"奇迹般"的化简。结论极其干净:
\[ \boxed{\ \frac{\partial L}{\partial z_2} = \hat{y} - y\ } \]也就是预测概率减去真实独热标签。直觉上太美了:如果模型对正确类已经预测为 1(\(\hat y=y\)),梯度就是 0,不用再改;预测得越离谱,梯度越大,改得越狠。这个结果第 9 课已经推过,这里直接拿来当反向传播的"起手式"。我们记 \(\delta_2 := \partial L/\partial z_2\)。
第二步:穿过第二层线性 \(z_2=W_2 a_1+b_2\)
这是上一课的标准结论。对一个线性层 \(z=Wa+b\),已知上游梯度 \(\delta=\partial L/\partial z\),则(分母布局下):
\[ \frac{\partial L}{\partial W_2}=\delta_2\, a_1^{\top},\qquad \frac{\partial L}{\partial b_2}=\delta_2,\qquad \frac{\partial L}{\partial a_1}=W_2^{\top}\,\delta_2. \]记住形状自检法:\(\delta_2\) 是 \(C\times1\),\(a_1^\top\) 是 \(1\times H\),外积得到 \(C\times H\),正好和 \(W_2\) 同形——分母布局的约定让一切自动对齐。最后一项 \(W_2^\top\delta_2\)(\(H\times C\) 乘 \(C\times 1\) 得 \(H\times1\))就是把梯度回传给 \(a_1\),准备穿过 ReLU。
第三步:穿过 ReLU
ReLU 是逐元素的 \(a_1=\max(0,z_1)\),它的导数是一个"开关":输入为正时导数为 1(信号原样通过),输入为负时导数为 0(信号被掐断)。所以
\[ \frac{\partial L}{\partial z_1}=\frac{\partial L}{\partial a_1}\odot \mathbf{1}[z_1>0], \]其中 \(\odot\) 是逐元素乘 element-wise product,\(\mathbf{1}[z_1>0]\) 是一个由 0/1 组成的掩码(mask)。记 \(\delta_1:=\partial L/\partial z_1\)。
这就是"梯度消失"最朴素的源头之一:凡是 \(z_1<0\) 的隐藏单元,反向时梯度被乘上 0,它对应的 \(W_1\) 那一部分这一步完全收不到学习信号。如果一个单元长期输出负值,它就成了"死亡 ReLU dead ReLU",永远学不动。理解了这一行乘法,你就理解了为什么后来有 LeakyReLU、GELU。
反过来也有它的"对偶":在很深的网络里,把第二步的回传 \(\delta_{\text{prev}}=W^\top\delta\) 一层层连乘下去,如果这些 \(W^\top\) 把梯度越放越大,最后传到浅层时数值会指数级膨胀甚至变 inf——这就是梯度爆炸 exploding gradient。它和梯度消失是同一个连乘机制的两个极端,具体的应对(梯度裁剪、归一化、残差连接)留到后面模块讲。
第四步:穿过第一层线性 \(z_1=W_1 x+b_1\)
和第二步一模一样的模式,只是把 \(a_1\) 换成输入 \(x\):
\[ \frac{\partial L}{\partial W_1}=\delta_1\, x^{\top},\qquad \frac{\partial L}{\partial b_1}=\delta_1. \]到这里四个梯度全部到手。注意整个过程只用了前向时存下来的中间量(\(x, z_1, a_1, \hat y\))和一路传下来的 \(\delta\),没有任何重复计算——这正是反向传播比"逐个参数算数值导数"快上千万倍的原因。
要点
反向传播 = 从 \(\delta_2=\hat y-y\) 起手,反复套用两条规则:
① 线性层 \(z=Wa+b\):dW = δ aᵀ,db = δ,把梯度回传 δ_prev = Wᵀ δ;
② 逐元素激活:上游梯度逐元素乘上该激活的导数(ReLU 就是乘 0/1 掩码)。
任意深的前馈网络,都是这两条规则的循环。
5. 例题:手推一遍全部梯度(带真实数字)
例题
取一个最小网络 \(D=2,\ H=3,\ C=2\),单个样本。给定
\[ x=\begin{bmatrix}1\\-2\end{bmatrix},\ W_1=\begin{bmatrix}0.5&-0.3\\0.1&0.2\\-0.4&0.6\end{bmatrix},\ b_1=\begin{bmatrix}0\\0.1\\-0.2\end{bmatrix},\ W_2=\begin{bmatrix}0.2&-0.1&0.4\\0.3&0.5&-0.2\end{bmatrix},\ b_2=\begin{bmatrix}0\\0.1\end{bmatrix}, \]真实类别是第 0 类,即 \(y=[1,0]^\top\)。请手推所有梯度。
前向。 先算 \(z_1=W_1x+b_1\):
\[ z_1=\begin{bmatrix}0.5\cdot1+(-0.3)(-2)+0\\0.1\cdot1+0.2(-2)+0.1\\-0.4\cdot1+0.6(-2)-0.2\end{bmatrix} =\begin{bmatrix}1.1\\-0.2\\-1.8\end{bmatrix},\quad a_1=\mathrm{ReLU}(z_1)=\begin{bmatrix}1.1\\0\\0\end{bmatrix}. \]再算 \(z_2=W_2a_1+b_2\)(注意 \(a_1\) 只有第一位非零):
\[ z_2=\begin{bmatrix}0.2\cdot1.1+0\\0.3\cdot1.1+0.1\end{bmatrix} =\begin{bmatrix}0.22\\0.43\end{bmatrix}. \]softmax(减最大值 0.43 稳定化):\(e^{0.22-0.43}=e^{-0.21}=0.8106,\ e^{0}=1\),归一化得
\[ \hat y=\begin{bmatrix}0.8106/1.8106\\1/1.8106\end{bmatrix}=\begin{bmatrix}0.4477\\0.5523\end{bmatrix},\qquad L=-\log(0.4477)=0.8036. \]反向。 起手式:
\[ \delta_2=\hat y-y=\begin{bmatrix}0.4477-1\\0.5523-0\end{bmatrix}=\begin{bmatrix}-0.5523\\0.5523\end{bmatrix}. \]第二层(\(\delta_2 a_1^\top\),外积;\(a_1=[1.1,0,0]\)):
\[ \frac{\partial L}{\partial W_2}=\begin{bmatrix}-0.5523\\0.5523\end{bmatrix}\begin{bmatrix}1.1&0&0\end{bmatrix} =\begin{bmatrix}-0.6075&0&0\\0.6075&0&0\end{bmatrix},\quad \frac{\partial L}{\partial b_2}=\begin{bmatrix}-0.5523\\0.5523\end{bmatrix}. \]回传到 \(a_1\):\(\partial L/\partial a_1=W_2^\top\delta_2\):
\[ W_2^\top\delta_2=\begin{bmatrix}0.2&0.3\\-0.1&0.5\\0.4&-0.2\end{bmatrix}\begin{bmatrix}-0.5523\\0.5523\end{bmatrix} =\begin{bmatrix}0.0552\\0.3314\\-0.3314\end{bmatrix}. \]过 ReLU:掩码 \(\mathbf{1}[z_1>0]=[1,0,0]\)(因为 \(z_1=[1.1,-0.2,-1.8]\)),逐元素相乘:
\[ \delta_1=\begin{bmatrix}0.0552\\0.3314\\-0.3314\end{bmatrix}\odot\begin{bmatrix}1\\0\\0\end{bmatrix}=\begin{bmatrix}0.0552\\0\\0\end{bmatrix}. \]第一层(\(\delta_1 x^\top\),\(x=[1,-2]\)):
\[ \frac{\partial L}{\partial W_1}=\begin{bmatrix}0.0552\\0\\0\end{bmatrix}\begin{bmatrix}1&-2\end{bmatrix} =\begin{bmatrix}0.0552&-0.1105\\0&0\\0&0\end{bmatrix},\quad \frac{\partial L}{\partial b_1}=\begin{bmatrix}0.0552\\0\\0\end{bmatrix}. \]注意 \(W_1\) 的后两行梯度全是 0——因为那两个隐藏单元被 ReLU 掐断了,这一步它们学不到任何东西。(也正因如此,这个例子里反向特别"稀疏";真实网络里多数单元都激活,各项一般都非零,动手练习第 1 题会让你体会满激活的一般情形。)这些数字在下面代码的"梯度检查"里会被有限差分逐一验证为正确。
6. batch 维度:N 个样本一起算
真实训练不会一次只喂一个样本,而是一批 batch \(N\) 个一起。沿用"行=样本"的约定,把数据堆成矩阵 \(X\in\mathbb{R}^{N\times D}\)(每行一个 \(x^\top\))。前向时所有公式从"矩阵×列向量"变成"矩阵×矩阵",且权重摆在右边:
\[ Z_1=XW_1+b_1\ (N\times H),\quad A_1=\mathrm{ReLU}(Z_1),\quad Z_2=A_1W_2+b_2\ (N\times C),\quad \hat Y=\mathrm{softmax}(Z_2). \](这里 \(W_1\) 取 \(D\times H\)、\(W_2\) 取 \(H\times C\),正好让"行样本"流畅地左乘;偏置 \(b\) 靠广播加到每一行。)损失是整批的平均:\(L=\frac1N\sum_{i}\mathrm{CE}(\hat y_i,y_i)\)。反向时的关键约定是:
- 权重梯度对 batch 求和、再除以 \(N\)(即平均)。\(\partial L/\partial Z_2=(\hat Y-Y)/N\),于是 \(\partial L/\partial W_2=A_1^\top\,(\hat Y-Y)/N\)。这个矩阵乘法里的求和 \(\sum_i\) 自动把所有样本的贡献加起来,除以 \(N\) 就是平均。
- 偏置梯度沿样本维 axis=0 求和,因为同一个 \(b\) 被加到了每个样本上,它的总梯度就是各样本贡献之和。注意求和与平均在这里只差一个"已经提前除掉的 \(N\)":我们传进来的本就是带 \(1/N\) 因子的 \((\hat Y-Y)/N\),所以
db = ((Ŷ−Y)/N).sum(axis=0)沿样本维加完,得到的恰好是 \(N\) 个样本梯度的平均——和权重梯度口径一致,无需再额外除 \(N\)。
形状上:\(A_1^\top\) 是 \(H\times N\),\((\hat Y-Y)/N\) 是 \(N\times C\),乘出来 \(H\times C\) 和 \(W_2\) 同形。漂亮地对齐。
易错
(1) 除以 \(N\) 不能漏,否则梯度大小随 batch 变化,学习率就得跟着调,很容易炸。(2) 偏置一定是 .sum(axis=0)(沿样本求和)而不是 .sum()(全求和成标量)。(3) batch 版里权重在右、转置在左,和单样本版"权重在左"相反——根源是"行样本"约定,别两套混用。
7. 梯度检查:怎么证明你没推错
推导和写代码都极易出符号错、转置错、漏除 \(N\)。怎么自证清白?用有限差分 finite difference 去逼近"真"梯度,和你的解析梯度比。对任意一个参数分量 \(\theta\),用中心差分(误差是 \(O(\epsilon^2)\),比单边差分准得多):
\[ \frac{\partial L}{\partial \theta}\approx\frac{L(\theta+\epsilon)-L(\theta-\epsilon)}{2\epsilon},\qquad \epsilon=10^{-5}. \]然后比较解析梯度 \(g_a\) 与数值梯度 \(g_n\) 的相对误差 relative error:
\[ \text{rel}=\frac{|g_a-g_n|}{|g_a|+|g_n|}. \]如果你的反向传播写对了,相对误差应当小于 \(10^{-7}\)(用 float64 时常能到 \(10^{-9}\))。如果某个参数误差是 \(10^{-2}\) 量级,那里几乎肯定有 bug。下面代码里 grad_check() 抽查若干分量,实测最大相对误差约 \(5\times10^{-9}\)——过关。
ML 和 ML 的联系
梯度检查是每个手写反向传播的人都要做的第一件事。框架时代你很少自己写 backward,但一旦你实现自定义算子(custom layer / custom CUDA kernel),PyTorch 的 torch.autograd.gradcheck 干的就是这件事。它慢(每个参数要两次前向),所以只在小网络、调试期用;训练时关掉。
8. 完整代码:合成数据 → 训练 → 梯度检查全过关
下面是一段纯 numpy、可直接在本页运行的完整实现:现场合成三团高斯点做三分类,跑梯度检查,再训练 800 步,打印 loss 下降与准确率。几秒内跑完。
import numpy as np
rng = np.random.default_rng(42)
# ---------- 1. 合成数据:3 团高斯点(故意让它们有重叠,问题才不平凡)----------
def make_blobs(n_per=200):
centers = np.array([[ 1.3, 0.0],
[-1.0, 1.1],
[-0.7, -1.1]])
X, y = [], []
for k, c in enumerate(centers):
pts = rng.normal(c, 0.95, size=(n_per, 2)) # 标准差大 -> 三团互相重叠
X.append(pts); y.append(np.full(n_per, k))
X = np.vstack(X); y = np.concatenate(y)
idx = rng.permutation(len(y))
return X[idx], y[idx]
X, y = make_blobs()
N, D = X.shape # N 样本, D=2 特征(行=样本)
C = 3 # 类别数
# np.eye(C) 是 C×C 单位矩阵;用标签数组 y 索引它的行,
# 把每个整数标签 k 取成第 k 行(第 k 位为 1 的独热向量),
# 一次性得到形状 (N, C) 的独热标签矩阵 Y。
Y = np.eye(C)[y]
H = 16 # 隐藏单元数
# ---------- 2. 参数初始化(He 初始化,配 ReLU)----------
# He 初始化:把权重的随机尺度设成 sqrt(2/扇入),目的是让前向信号
# 不逐层放大或缩小、训练能顺利起步。为什么恰好是 2/n 留到优化模块讲,
# 现在只需照用即可。
def init():
return (rng.normal(0, np.sqrt(2/D), (D, H)), # W1 (D,H)
np.zeros(H), # b1
rng.normal(0, np.sqrt(2/H), (H, C)), # W2 (H,C)
np.zeros(C)) # b2
# ---------- 3. 前向 + 损失 ----------
def softmax(Z):
Z = Z - Z.max(axis=1, keepdims=True) # log-sum-exp 稳定化(按行减最大值)
e = np.exp(Z)
return e / e.sum(axis=1, keepdims=True)
def forward(X, W1, b1, W2, b2):
z1 = X @ W1 + b1 # (N,H)
a1 = np.maximum(z1, 0) # ReLU
z2 = a1 @ W2 + b2 # (N,C)
yhat = softmax(z2)
return yhat, (X, z1, a1, z2, yhat)
def loss_fn(yhat, Y):
N = Y.shape[0]
return -np.sum(Y * np.log(yhat + 1e-12)) / N # 加 1e-12 防 log(0)
# ---------- 4. 反向(本课的核心,对照第 4、6 节公式)----------
def backward(cache, Y, W2):
X, z1, a1, z2, yhat = cache
N = X.shape[0]
dz2 = (yhat - Y) / N # 起手式 ŷ-y,并对 batch 平均
dW2 = a1.T @ dz2 # (H,C) = A1ᵀ δ2
db2 = dz2.sum(axis=0) # (C,) 偏置沿样本维求和
da1 = dz2 @ W2.T # (N,H) 回传到 a1
dz1 = da1 * (z1 > 0) # 过 ReLU:逐元素乘 0/1 掩码
dW1 = X.T @ dz1 # (D,H) = Xᵀ δ1
db1 = dz1.sum(axis=0) # (H,)
return dW1, db1, dW2, db2
# ---------- 5. 梯度检查:有限差分 vs 解析梯度 ----------
def grad_check():
W1, b1, W2, b2 = init()
Xs, Ys = X[:8], Y[:8] # 小批量足够
_, cache = forward(Xs, W1, b1, W2, b2)
grads = dict(zip(['W1','b1','W2','b2'], backward(cache, Ys, W2)))
params = {'W1':W1, 'b1':b1, 'W2':W2, 'b2':b2}
eps, worst = 1e-5, 0.0
for name, P in params.items():
g_ana = grads[name]
for _ in range(20): # 每个参数抽查 20 个分量
i = tuple(rng.integers(0, s) for s in P.shape)
old = P[i]
P[i] = old + eps; Lp = loss_fn(forward(Xs,W1,b1,W2,b2)[0], Ys)
P[i] = old - eps; Lm = loss_fn(forward(Xs,W1,b1,W2,b2)[0], Ys)
P[i] = old
g_num = (Lp - Lm) / (2*eps) # 中心差分
rel = abs(g_num - g_ana[i]) / max(1e-12, abs(g_num) + abs(g_ana[i]))
worst = max(worst, rel)
print(f"梯度检查最大相对误差 = {worst:.2e} (应 < 1e-7)")
grad_check()
# ---------- 6. 训练循环 ----------
W1, b1, W2, b2 = init()
eta = 0.4 # 学习率
print("开始训练:")
for step in range(1, 801):
yhat, cache = forward(X, W1, b1, W2, b2)
L = loss_fn(yhat, Y)
dW1, db1, dW2, db2 = backward(cache, Y, W2)
W1 -= eta*dW1; b1 -= eta*db1 # 梯度下降一步
W2 -= eta*dW2; b2 -= eta*db2
if step in (1, 50, 100, 200, 400, 800):
acc = (yhat.argmax(1) == y).mean()
print(f" step {step:4d} loss {L:.4f} acc {acc:.3f}")
yhat, _ = forward(X, W1, b1, W2, b2)
print(f"最终训练准确率 = {(yhat.argmax(1)==y).mean():.3f}")
运行后你会看到类似输出(数字因随机种子固定而可复现):梯度检查约 5e-09 过关;loss 从 step 1 的 1.8 左右一路降下来,前 50 步降得最猛(50 步时已到 0.42 上下、基本收敛),之后缓慢微降,到 800 步稳定在 0.40 附近,准确率到 0.84 左右。三团点故意做成重叠的,所以达不到 100%——这恰恰说明它在真学,而不是死记。下图是 loss 随步数下降的示意(前陡后缓):
试着把 eta 调到 5.0(loss 可能震荡或变 nan),或调到 0.01(降得极慢),亲手感受学习率的作用;再把 H 改成 2,看看模型容量不够时准确率怎么掉下来。
9. micrograd:把"手推链式"升级成"自动反向"
上面我们是手工为这个特定网络写 backward。但框架(PyTorch/TensorFlow)不可能为每种网络手写——它们用自动微分 automatic differentiation。核心思想由 Karpathy 的玩具库 micrograd 讲得最透彻:把每个标量包成一个带 grad 字段的节点,记住它是由哪些节点、经什么运算得来的;每个运算自己知道怎么把上游梯度传给输入。前向时顺手把计算图连起来,调用 .backward() 时按拓扑序倒着走一遍,每个节点执行自己的局部链式法则——这正是我们第 4 节做的事,只是自动化了。
import numpy as np
class Value:
"""一个标量 + 它的梯度 + 它怎么算出来的(自动微分最小内核)"""
def __init__(self, data, _children=()):
self.data = float(data)
self.grad = 0.0
self._backward = lambda: None # 该节点把梯度回传给输入的局部规则
self._prev = set(_children)
def __add__(self, o):
o = o if isinstance(o, Value) else Value(o)
out = Value(self.data + o.data, (self, o))
def _bw(): # d(a+b)/da = 1, d(a+b)/db = 1
self.grad += out.grad
o.grad += out.grad
out._backward = _bw
return out
def __mul__(self, o):
o = o if isinstance(o, Value) else Value(o)
out = Value(self.data * o.data, (self, o))
def _bw(): # d(a*b)/da = b, d(a*b)/db = a
self.grad += o.data * out.grad
o.grad += self.data * out.grad
out._backward = _bw
return out
def relu(self):
out = Value(max(0.0, self.data), (self,))
def _bw(): # ReLU 导数:输入>0 时 1,否则 0
self.grad += (out.data > 0) * out.grad
out._backward = _bw
return out
def backward(self):
# 1) 拓扑排序:保证回传一个节点前,它的所有"下游"已处理完。
# 直觉像倒水:必须先处理离输出最近的节点,再处理喂给它的上游节点;
# 这样轮到某节点时,所有用到它的下游节点都已把各自梯度累加进来。
topo, seen = [], set()
def build(v):
if v not in seen:
seen.add(v)
for ch in v._prev:
build(ch)
topo.append(v)
build(self)
# 2) 从输出开始,梯度设 1,倒序执行每个节点的局部规则
self.grad = 1.0
for v in reversed(topo):
v._backward()
# 验证:算一个神经元 n = relu(x*w + b) 对各输入的梯度
x, w, b = Value(2.0), Value(3.0), Value(1.0)
n = (x*w + b).relu() # relu(2*3+1) = relu(7) = 7
n.backward()
print("n =", n.data) # 7.0
print("dx =", x.grad) # = w = 3.0
print("dw =", w.grad) # = x = 2.0
print("db =", b.grad) # = 1.0
# 换成会被 ReLU 掐断的情形:relu(-5)=0,梯度全为 0
x2, w2, b2 = Value(-2.0), Value(3.0), Value(1.0)
n2 = (x2*w2 + b2).relu() # relu(-6+1) = relu(-5) = 0
n2.backward()
print("n2 =", n2.data, " dx =", x2.grad, " dw =", w2.grad, " db =", b2.grad)
运行会打印 n=7.0, dx=3.0, dw=2.0, db=1.0,第二组因 ReLU 截断全是 0——和我们手推的规则完全一致。把 Value 扩展到向量/矩阵、再加上 exp、log 等运算,就是一个迷你 PyTorch。第 8 节那个网络的每一步 .backward(),本质就是这套机制在矩阵上的批量版本。
要点
loss.backward() 没有任何魔法:前向时把运算连成计算图,反向时按拓扑逆序让每个节点执行自己的局部链式法则,梯度用 += 累加(因为一个节点可能被多处用到——这也是为什么 PyTorch 要 zero_grad() 清零)。你手推的两层网络反向,就是这台机器跑出的结果。
10. 小结:你刚刚打通了整个模块 1
回头看,这一课把前九课串成了一条完整链路:向量与矩阵是数据和参数的载体,内积/线性变换是每一层的运算,导数与梯度是"往哪改"的方向,链式法则与矩阵求导是反向传播的引擎,而 softmax/交叉熵把它接到分类任务上。你现在能从损失一路手推到第一层权重的梯度、用代码实现它、用梯度检查证明它对、看着它把 loss 真的降下来——这是理解一切深度模型训练的地基。
ML 和 ML 的联系
以后调模型遇到 loss 变 NaN,你会第一时间想到 softmax 没做 log-sum-exp 稳定化、或 log(0);遇到 梯度消失,你会想到 ReLU 掩码把信号乘成了 0、或链太长导致 \(W^\top\) 连乘衰减;遇到 梯度爆炸,你会想到同一条 \(W^\top\) 连乘反过来把梯度越放越大;遇到 loss 不降,你会去查学习率、初始化、或梯度方向写反了。这些"据",全部来自你今天亲手走过的那一遍反向传播。
调一调,观察现象
下面三个微任务都基于本课已验证的代码,改一个地方、跑几秒,亲手看反向传播的几个关键约定"坏掉"或"生效"。建议每个都先猜结果再运行。
任务 1:扫学习率,看收敛 / 减速 / 变差
改什么:对同一网络,把学习率 eta 依次设为 0.01、0.4、5.0,各训练 300 步,打印最终 loss 与准确率。
预期看到:eta=0.4 收敛得最充分(loss≈0.40、acc≈0.835);eta=0.01 降得太慢、300 步还没收敛到位(loss 仍≈0.45,比 0.4 那档高出一截,而 acc 恰好也到了 0.835——准确率先到顶、loss 还在慢慢压);eta=5.0 步子太大、反而把 loss 顶高(loss≈0.52、acc 掉到 0.78)。
为什么:梯度下降每步沿负梯度走 eta 倍。太小则每步挪动太少、loss 收敛慢;太大则越过谷底来回振荡、loss 不降反升。学习率是本课训练循环里最敏感的旋钮。
import numpy as np
rng = np.random.default_rng(42)
def make_blobs(n_per=200):
centers = np.array([[1.3,0.0],[-1.0,1.1],[-0.7,-1.1]])
X,y=[],[]
for k,c in enumerate(centers):
X.append(rng.normal(c,0.95,size=(n_per,2))); y.append(np.full(n_per,k))
X=np.vstack(X); y=np.concatenate(y); idx=rng.permutation(len(y))
return X[idx],y[idx]
X,y=make_blobs(); N,D=X.shape; C=3; Y=np.eye(C)[y]; H=16
def softmax(Z):
Z=Z-Z.max(axis=1,keepdims=True); e=np.exp(Z); return e/e.sum(axis=1,keepdims=True)
def forward(X,W1,b1,W2,b2):
z1=X@W1+b1; a1=np.maximum(z1,0); z2=a1@W2+b2; return softmax(z2),(X,z1,a1,z2)
def loss_fn(yh,Y): return -np.sum(Y*np.log(yh+1e-12))/Y.shape[0]
def backward(c,Y,W2):
X,z1,a1,z2=c; N=X.shape[0]
dz2=(softmax(z2)-Y)/N; dW2=a1.T@dz2; db2=dz2.sum(0)
dz1=(dz2@W2.T)*(z1>0); return X.T@dz1, dz1.sum(0), dW2, db2
for eta in [0.01,0.4,5.0]:
r=np.random.default_rng(0)
W1=r.normal(0,np.sqrt(2/D),(D,H)); b1=np.zeros(H)
W2=r.normal(0,np.sqrt(2/H),(H,C)); b2=np.zeros(C)
for _ in range(300):
yh,c=forward(X,W1,b1,W2,b2); dW1,db1,dW2,db2=backward(c,Y,W2)
W1-=eta*dW1; b1-=eta*db1; W2-=eta*dW2; b2-=eta*db2
yh,_=forward(X,W1,b1,W2,b2)
print(f"eta={eta:<5} loss={loss_fn(yh,Y):.4f} acc={(yh.argmax(1)==y).mean():.3f}")
任务 2:删掉 log-sum-exp,亲眼看 nan
改什么:对同一组 logits,一次用"不减最大值"的 softmax,一次用"按行减最大值"的稳定版,其中一个 logit 故意取 1000。
预期看到:不稳定版打印 [0, 0, nan](还伴随 overflow / invalid value 警告);稳定版打印 [0, 0, 1],干净正确。
为什么:\(e^{1000}\) 直接上溢成 inf,inf/inf=nan。按行减最大值后指数输入最大是 0、\(e^0=1\) 不溢出,而结果因分子分母同乘 \(e^{-m}\) 完全不变。这就是代码里那行 Z - Z.max(...) 一个都不能省的原因。
import numpy as np
z = np.array([[1.0, 2.0, 1000.0]])
e_bad = np.exp(z)
print("unstable:", e_bad / e_bad.sum(axis=1, keepdims=True))
zs = z - z.max(axis=1, keepdims=True)
e = np.exp(zs)
print("stable: ", e / e.sum(axis=1, keepdims=True))
任务 3:把偏置求和写错轴,看梯度走样
改什么:对同一个上游梯度 dz2(形状 (N,C)),分别用 dz2.sum(axis=0)(正确,沿样本维)和 dz2.sum()(错误,全求和成标量)算偏置梯度。
预期看到:正确版是形状 (C,) 的向量 [-0.2994, 0.4063, 0.241],每类一个值;错误版塌成一个标量 0.3479。把这个标量当 db2 去更新会被广播到所有类、方向全错——若拿它跑本课的 grad_check(),b2 的相对误差会从约 5.8e-9 飙到 1e0 量级(接近 1.0,这是该相对误差度量的上限),一眼就能看出"反向写错了"。
为什么:同一个 \(b\) 被加到每个样本上,它的总梯度是各样本贡献沿样本维(axis=0)之和,每个类别各自独立;.sum() 把不同类别也加到一起,丢掉了"每类一个偏置"的结构。
import numpy as np
rng = np.random.default_rng(1)
N, C = 8, 3
dz2 = rng.normal(size=(N, C)) / N
print("correct db (axis=0):", dz2.sum(axis=0).round(4), "shape", dz2.sum(axis=0).shape)
print("wrong db (scalar):", round(float(dz2.sum()), 4))
动手练习
- 手推 + 复核。 用第 5 节的同一组数字,但把真实标签改成第 1 类(\(y=[0,1]^\top\)),手推出 \(\delta_2,\ \partial L/\partial W_2,\ \partial L/\partial W_1\),再用下面骨架核对。进阶:再换一组让 \(z_1\) 全为正的输入(例如 \(x=[2,1]^\top\))重推一遍,体会当所有隐藏单元都被激活时,各梯度项都非零、外积是"满"的——不像例题里那样退化得稀疏。
import numpy as np x=np.array([1.,-2.]); W1=np.array([[.5,-.3],[.1,.2],[-.4,.6]]); b1=np.array([0.,.1,-.2]) W2=np.array([[.2,-.1,.4],[.3,.5,-.2]]); b2=np.array([0.,.1]) y=np.array([0.,1.]) z1=W1@x+b1; a1=np.maximum(z1,0); z2=W2@a1+b2 e=np.exp(z2-z2.max()); yh=e/e.sum() dz2=yh-y print("dz2",dz2) print("dW2",np.outer(dz2,a1)) da1=W2.T@dz2; dz1=da1*(z1>0) print("dW1",np.outer(dz1,x)) - 去掉稳定化看溢出。 把第 8 节
softmax里的Z - Z.max(...)删掉,并把某个输入特征乘以 1000(X = X*1000),运行看是否出现nan;再加回稳定化,确认恢复正常。 - 偏置约定实验。 把
backward里的db2 = dz2.sum(axis=0)误写成db2 = dz2.sum()(求成标量),重新跑grad_check(),观察相对误差从约5e-9跳到多大——亲手见证"偏置要沿样本维求和"。 - 学习率扫描。 写个循环对
eta in [0.01, 0.1, 0.4, 1.5, 5.0]各训练 300 步,打印最终 loss 与准确率,找出最好的eta,并解释太大/太小各发生了什么。 - 给 micrograd 加新运算。 给第 9 节的
Value类加一个tanh方法(前向np.tanh(x),反向局部导数1 - tanh(x)**2),用backward()验证它对一个标量输入的梯度,与中心差分对比。
掌握自检
- 我能不看讲义,从 \(\delta_2=\hat y-y\) 起手,独立推出四个梯度,并说出每个矩阵乘法里转置和左右摆放的理由(靠形状自检)。
- 我能解释 log-sum-exp 稳定化为什么不改变 softmax 结果,以及为什么要按行减最大值。
- 我能说清 batch 维下"权重梯度对 \(N\) 求平均、偏置沿样本维求和"的来历,知道偏置的求和与平均只差那个已被 \((\hat Y-Y)/N\) 提前吸收的 \(N\),并知道漏掉除 \(N\) 会怎样。
- 我能写出中心差分公式,并知道梯度检查的相对误差应当 \(<10^{-7}\),否则代码有 bug。
- 我能用一句话说明
loss.backward()的本质(计算图 + 拓扑逆序 + 局部链式法则 + 梯度累加),并解释 ReLU 掩码如何引起梯度消失、\(W^\top\) 连乘如何引起梯度消失或爆炸。
到这里,模块 1(线性代数与微积分)全部完成——你已经备齐了驱动一切模型的数学引擎。下一模块我们换一套语言:进入概率、信息论与最优化(含强化学习速成),去回答"模型该有多自信""信息怎么量化""梯度下降之外还有哪些更聪明的优化器"。
可以先放过的点
- He 初始化里那个 \(\sqrt{2/n}\) 为什么恰好是 2 而不是别的数——本课照用即可,背后的方差推导等到下一模块《概率、信息论与最优化》讲优化与初始化时再回来,会一下子通透。
- 梯度爆炸的应对手段(梯度裁剪、归一化、残差连接)现在只要知道"和梯度消失是同一个 \(W^\top\) 连乘机制的两个极端"就够了,具体方法是后面深度网络模块的内容,不必现在硬记。
- micrograd 里
backward()的拓扑排序代码看不懂没关系——抓住"先处理离输出近的节点、梯度用 += 累加"这个直觉即可,等你真要写自定义算子时再逐行抠。 - softmax+交叉熵合并求导那一步的雅可比化简(为什么偏偏化简成 \(\hat y-y\))第 9 课已给过,这里当结论用,暂时不必自己再推一遍。