反向传播:从手算梯度,到手写框架,最后拿去预测房价
一句话版:这篇文章用一个带真实数字的小网络,把"梯度到底怎么算出来"讲透;再把这份理解重构成一个 5 层分明的迷你深度学习框架
minidnn,并拿它做了个房价预测 demo。全程只需要高中导数和最基础的矩阵知识。配套代码:
backprop_lab.py(4 步递进实验)、minidnn/(迷你框架)、demo_house_price.py(房价预测应用)。
写在前面:为什么你要自己手写一遍
loss.backward() 只有一行,但"会用"和"懂"是两回事。你大概经历过这样的时刻:模型 train 起来了,loss 降了,准确率 99%——可一旦问"它到底在做什么?",就卡住了:
- 梯度是什么?凭什么是它来决定权重往哪改?
- 链式法则为什么要"连乘"?
.backward()凭什么能自动算出所有参数的梯度?
这篇博客的目标只有一个:把这三个问题,从"背结论"变成"亲眼算一遍"。而且做完你会发现,PyTorch 在你眼里会变成一个透明盒子。
适合读者:学过导数、知道矩阵乘法(哪怕已经忘得差不多)、想真正理解深度学习训练的人。
你将收获:一张计算图、一个 60 行的 autograd 内核、一个能预测房价的迷你框架、以及"以后再也不用怕算没算对"的信心。
1. 训练到底在干嘛:在"损失曲面"上找最低点
模型参数 WWW 定义了一个损失 L(W)L(W)L(W)。训练 = 沿着 LLL 下降最快的方向(负梯度 −∇L-\nabla L−∇L)反复迈步:
W←W−η ∇L(W)W \leftarrow W - \eta\,\nabla L(W)W←W−η∇L(W)
直观上就是:站在一座"损失山"上,每次都朝最陡的下坡方向走一小步,走很多次,直到走到山谷(损失最低点)。
而全篇文章最大的悬念只有一个:∇L\nabla L∇L 怎么算? —— 这就是反向传播存在的全部理由。下面我们用一组真实数字把它算出来。
2. 链式法则:拿一组真实数字,从零算到底
先花 30 秒补两个高数概念,后面全靠它俩:
- 导数 = 局部斜率:f′(x)=limh→0f(x+h)−f(x)hf'(x)=\lim_{h\to0}\frac{f(x+h)-f(x)}{h}f′(x)=limh→0hf(x+h)−f(x)。直觉就是"输入动一点点,输出动多少"。
- 链式法则 = 各层斜率相乘:复合函数一层套一层,总变化率 = 各层变化率之积。类比工厂流水线:原料涨 1 元 → 中间件涨 0.8 元 → 成品涨 0.8×3=2.40.8\times3=2.40.8×3=2.4 元。乘法就是在算"传导放大"的总效果。
∂\partial∂ 读作"偏导",和普通导数 ddd 意思一样,只是提醒你"这函数有好几个变量,我们只看其中一个"。
2.1 一个带真实数字的小网络
设网络是 out=w2⋅tanh(w1x+b1)+b2out = w_2\cdot\tanh(w_1 x+b_1)+b_2out=w2⋅tanh(w1x+b1)+b2,目标 y=0.9y=0.9y=0.9,损失 L=(out−y)2L=(out-y)^2L=(out−y)2。参数挑好算的:x=0.5, w1=1.5, b1=−0.5, w2=0.8, b2=0.1x=0.5,\ w_1=1.5,\ b_1=-0.5,\ w_2=0.8,\ b_2=0.1x=0.5, w1=1.5, b1=−0.5, w2=0.8, b2=0.1。
① 前向传播:从 xxx 一路算到 LLL。
| 步骤 | 公式 | 代入数值 | 结果 |
|---|---|---|---|
| 线性 1 | z=w1x+b1z=w_1x+b_1z=w1x+b1 | 1.5×0.5+(−0.5)1.5\times0.5+(-0.5)1.5×0.5+(−0.5) | z=0.25z=0.25z=0.25 |
| 激活 | a=tanh(z)a=\tanh(z)a=tanh(z) | tanh(0.25)\tanh(0.25)tanh(0.25) | a≈0.2449a\approx0.2449a≈0.2449 |
| 线性 2 | out=w2a+b2out=w_2a+b_2out=w2a+b2 | 0.8×0.2449+0.10.8\times0.2449+0.10.8×0.2449+0.1 | out≈0.2959out\approx0.2959out≈0.2959 |
| 损失 | L=(out−y)2L=(out-y)^2L=(out−y)2 | (0.2959−0.9)2(0.2959-0.9)^2(0.2959−0.9)2 | L≈0.3649L\approx0.3649L≈0.3649 |
② 反向传播:从 LLL 往回,每步只算"局部斜率"。
| 层 | 局部斜率公式 | 代入数值 | 结果 |
|---|---|---|---|
| 输出 | ∂L∂out=2(out−y)\frac{\partial L}{\partial out}=2(out-y)∂out∂L=2(out−y) | 2×(0.2959−0.9)2\times(0.2959-0.9)2×(0.2959−0.9) | −1.2081-1.2081−1.2081 |
| 线性 2 | ∂out∂a=w2\frac{\partial out}{\partial a}=w_2∂a∂out=w2 | 0.80.80.8 | 0.80.80.8 |
| 激活 | ∂a∂z=1−a2\frac{\partial a}{\partial z}=1-a^2∂z∂a=1−a2 | 1−0.244921-0.2449^21−0.24492 | 0.94000.94000.9400 |
| 线性 1 | ∂z∂w1=x\frac{\partial z}{\partial w_1}=x∂w1∂z=x | 0.50.50.5 | 0.50.50.5 |
③ 链式法则 = 四段局部斜率连乘:
∂L∂w1=(−1.2081)×0.8×0.9400×0.5≈−0.4543\frac{\partial L}{\partial w_1}=(-1.2081)\times0.8\times0.9400\times0.5\approx-0.4543∂w1∂L=(−1.2081)×0.8×0.9400×0.5≈−0.4543
含义:w1w_1w1 每增大 1,LLL 大约下降 0.45(负号 = 方向相反)。
④ 数值梯度验证:不靠公式,用"导数定义"硬算。w1=1.5001w_1=1.5001w1=1.5001 时 L≈0.3648492L\approx0.3648492L≈0.3648492,w1=1.4999w_1=1.4999w1=1.4999 时 L≈0.3649400L\approx0.3649400L≈0.3649400,于是
0.3648492−0.36494002×10−4≈−0.4543\frac{0.3648492-0.3649400}{2\times10^{-4}}\approx-0.45432×10−40.3648492−0.3649400≈−0.4543
两条完全独立的路径算出同一个答案 → 链式法则没算错。这就是代码里"数值梯度验证(差 ~1e-11)"的原理:以后再也不怕算没算对。
⑤ 梯度下降一步:梯度是负的,说明 w1w_1w1 应该变大。取 η=0.1\eta=0.1η=0.1:
w1: 1.5→1.5−0.1×(−0.4543)=1.5454w_1:\ 1.5\to1.5-0.1\times(-0.4543)=1.5454w1: 1.5→1.5−0.1×(−0.4543)=1.5454
重跑前向,LLL 从 0.36490.36490.3649 降到 ≈0.3447\approx0.3447≈0.3447——损失确实变小了。训练 = 把 ②③⑤ 重复几千次。
顺带看一眼 tanh 长什么样、以及那个 1−a21-a^21−a2 的斜率是怎么来的:

关于"谁是谁的导数":导数永远问的是谁影响谁。反向传播只关心相邻两层——LLL 依赖 outoutout、outoutout 依赖 aaa、aaa 依赖 zzz、zzz 依赖 w1w_1w1,像拆积木一样从后往前拆,每段当成单变量求导即可。记住三块积木(线性层 y=wx+by=wx+by=wx+b、激活层 a=tanh(z)a=\tanh(z)a=tanh(z)、损失 L=(out−y)2L=(out-y)^2L=(out−y)2 的局部导数),任何网络都能拼。
3. autograd:把链式法则自动化(60 行)
第 2 节的手算有个明显问题:网络一大就手算不动了。怎么办?把"沿计算图反向"这件事自动化:
- 前向时,每个运算记成一个节点(值 + 输入 + 反向函数),自动织成一张计算图;
- 反向时,按拓扑序从输出倒着遍历,逐个调用每个节点存好的反向函数,累积梯度。
这就是 Step 2 里那个 Value 类(约 60 行)做的事——PyTorch .backward() 的最小内核。跑出来和手算完全一致:
自动求导梯度(应与 Step 1 手算一致):
w1.grad=+0.400000 b1.grad=+0.800000 w2.grad=+0.000000 b2.grad=+1.000000
3.1 计算图到底长什么样
拿第 2 节那个网络 out=w2⋅tanh(w1x+b1)+b2out = w_2\cdot\tanh(w_1 x + b_1) + b_2out=w2⋅tanh(w1x+b1)+b2,把每个运算拆成一个节点,数据流向就是一张图:
每个节点"记账"记三样东西(对应 Value 类的三个字段):
| 节点记的 | 对应代码 | 干嘛用 |
|---|---|---|
| ① 前向算出的值 | self.data | 反向算局部导数要用(如 tanh 的 1−a21-a^21−a2 要用 aaa) |
| ② 输入是谁 | self._prev | 反向时知道该往哪些节点传梯度 |
| ③ 反向函数 | self._backward | 这个运算的局部导数(查表可得) |
前向:从左往右,每个节点算一个数并存下来(代入第 2 节数字):
| 节点 | 计算 | 结果 |
|---|---|---|
| n1 | (−1.0)×0.5(-1.0)\times 0.5(−1.0)×0.5 | -0.5 |
| z | −0.5+0.5-0.5 + 0.5−0.5+0.5 | 0.0 |
| a | tanh(0)\tanh(0)tanh(0) | 0.0 |
| m | 0.8×00.8\times 00.8×0 | 0.0 |
| out | 0+0.20 + 0.20+0.2 | 0.2 |
反向:从右往左(逆着箭头),每到一个节点调用它的反向函数、把梯度往下传:
| 反向节点 | 局部导数 | 梯度 |
|---|---|---|
| out = m+b2 | dm=1, db2=1dm=1,\ db_2=1dm=1, db2=1 | dm=1, db2=1dm=1,\ db_2=1dm=1, db2=1 |
| m = w2·a | da=1×w2=0.8, dw2=1×a=0da=1\times w_2=0.8,\ dw_2=1\times a=0da=1×w2=0.8, dw2=1×a=0 | da=0.8, dw2=0da=0.8,\ dw_2=0da=0.8, dw2=0 |
| a = tanh(z) | dz=0.8×(1−02)=0.8dz=0.8\times(1-0^2)=0.8dz=0.8×(1−02)=0.8 | dz=0.8dz=0.8dz=0.8 |
| z = n1+b1 | dn1=0.8, db1=0.8dn_1=0.8,\ db_1=0.8dn1=0.8, db1=0.8 | dn1=0.8, db1=0.8dn_1=0.8,\ db_1=0.8dn1=0.8, db1=0.8 |
| n1 = w1·x | dw1=0.8×0.5=0.4dw_1=0.8\times 0.5=0.4dw1=0.8×0.5=0.4 | dw1=0.4dw_1=0.4dw1=0.4 |
最后梯度落在参数上:dw1=0.4, db1=0.8, dw2=0, db2=1dw_1=0.4,\ db_1=0.8,\ dw_2=0,\ db_2=1dw1=0.4, db1=0.8, dw2=0, db2=1——和手算、和 Value.backward() 完全一致。
一句话:计算图 = 前向时把每个运算记成一个节点(值 + 输入 + 反向函数);反向时照着图从后往前遍历,每步只算局部导数。图建好了,梯度自动就有——attention 也只是一张更大的图而已。
从此记住一句:任何深度学习框架的
.backward(),本质都是这张计算图上的链式法则。规模从 1 个神经元到 175B 参数,机制完全一样。
4. 向量化:从标量到矩阵(GEMM)
真实训练一次要算一批数据。Step 1 的标量公式变成矩阵版,但导数是同一套链式法则:
dW2 = h.T @ d_out # 输出层梯度
dz = dh * (1 - h*h) # tanh 的导数,和手算的 1-a² 一模一样
这也解释了那个你很熟悉的硬件现象:前向/反向的本质都是矩阵乘,所以 AI 加速器(GPU/NPU/TPU)的核心就是 GEMM(通用矩阵乘)阵列。
5. PyTorch 对照:你刚手写的东西就是它
同样的模型用 torch 写,对比 API:
| 你手写的 | PyTorch |
|---|---|
Value.backward() 反向遍历 | loss.backward() |
p.data -= lr * p.grad 更新 | opt.step() |
W1, b1, W2, b2 手动管理 | nn.Linear 自动封装 |
唯一差别:框架把"构图 + 求导"全自动了。你手写的 4 步实验,跑出来的准确率和 PyTorch 完全一致(都是 99.5%)——这大概是最有说服力的"我懂了"证据。
6. 动手前,先记住两个坑
写代码时我踩过两个很有意思的坑,都是干货:
- 发散(NaN):一批数据的梯度求和后,lr 过大 + 输出层初始化过大 → 权重正反馈爆炸。教训:输出层初始化要小、lr 要配平。
- 准确率假 50%:
(out>0) == y拿布尔值和浮点标签比,False == -1.0恒为 False,负类永远算错。教训:评估指标本身也可能有 bug,用(out*y)>0比符号才正确。
这两条说明:调参和写评估指标,都是训练里绕不开的工程必修课。
7. 重构成一个分层清晰的迷你框架:minidnn
原实验代码什么都好,就是所有东西揉在一起。我把它重构成 5 个文件 = 5 个层,每层只做一件事,并且和 PyTorch 一一对应:
minidnn 文件 | 对应 PyTorch | 职责 |
|---|---|---|
minidnn/engine.py | torch.Tensor | 张量 + 自动求导(计算图) |
minidnn/nn.py | torch.nn | Module / Linear / Tanh / Sequential |
minidnn/loss.py | torch.nn.MSELoss | 损失函数 |
minidnn/optim.py | torch.optim.SGD | 优化器(更新参数) |
minidnn/train.py | 手写训练循环 | 前向 → 损失 → 反向 → 更新 |
核心洞察:整个框架真正"算导数"的地方只有 4 处(@ 矩阵乘 / + 加法 / tanh / **2 平方),其余全是沿计算图把局部导数连乘——和第二节手算的完全同一件事。
7.1 一个 Linear 层的一生:矩阵公式 + 具体数字演算
关键认知:一个 Linear 层就是第 2 节那个标量神经元的"复制 + 批量"版——Linear(3,1) 是 z=w1x1+w2x2+w3x3+bz=w_1x_1+w_2x_2+w_3x_3+bz=w1x1+w2x2+w3x3+b(3 输入 1 输出),Linear(3,8) 就是把它复制 8 份,WWW 是 (3,8)(3,8)(3,8) 矩阵,每一列 = 一个第 2 节式的权重向量。
(0) 建层:
self.W = Tensor(np.random.randn(3, 8) * 0.5) # W: (3,8) 随机初始化
self.b = Tensor(np.zeros((1, 8))) # b: (1,8) 初始 0
(1) 前向:核心就一行 x @ self.W + self.b,数学上是一次矩阵运算:
Z=X W+b,X:(N,3), W:(3,8), b:(1,8), Z:(N,8)Z = X\,W + b,\qquad X:(N,3),\ W:(3,8),\ b:(1,8),\ Z:(N,8)Z=XW+b,X:(N,3), W:(3,8), b:(1,8), Z:(N,8)
用具体数字走一遍(为好算取 Linear(3,1),2 套房):
X=[123456],W=[0.5−1.00.2],b=[0.1]X=\begin{bmatrix}1&2&3\\4&5&6\end{bmatrix},\quad W=\begin{bmatrix}0.5\\-1.0\\0.2\end{bmatrix},\quad b=\begin{bmatrix}0.1\end{bmatrix}X=[142536],W=0.5−1.00.2,b=[0.1]
Z=XW+b=[1⋅0.5+2⋅(−1)+3⋅0.24⋅0.5+5⋅(−1)+6⋅0.2]+[0.1]=[−0.8−1.7]Z=XW+b=\begin{bmatrix}1\cdot0.5+2\cdot(-1)+3\cdot0.2\\4\cdot0.5+5\cdot(-1)+6\cdot0.2\end{bmatrix}+\begin{bmatrix}0.1\end{bmatrix}=\begin{bmatrix}-0.8\\-1.7\end{bmatrix}Z=XW+b=[1⋅0.5+2⋅(−1)+3⋅0.24⋅0.5+5⋅(−1)+6⋅0.2]+[0.1]=[−0.8−1.7]
即第 1 套房:0.5−2+0.6+0.1=−0.80.5-2+0.6+0.1=-0.80.5−2+0.6+0.1=−0.8;第 2 套房:2−5+1.2+0.1=−1.72-5+1.2+0.1=-1.72−5+1.2+0.1=−1.7。每行是一个样本、每列是一个神经元——这就是"批处理"。
(2) 自动构图(在 engine.py 里):x @ self.W 触发 __matmul__,它只写了两行局部导数公式:
def __matmul__(self, other): # self = x(N,3), other = W(3,8)
out = Tensor(self.data @ other.data, (self, other), "@")
def _bw():
self.grad += out.grad @ other.data.T # dX = dZ·Wᵀ (N,3)
other.grad += self.data.T @ out.grad # dW = Xᵀ·dZ (3,8)
out._backward = _bw
(3) 反向:下一层传回 dZ=∂L/∂ZdZ=\partial L/\partial ZdZ=∂L/∂Z 后,链式法则的向量版是一套三连:
dW=X⊤dZ,db=∑n=1NdZn,:,dX=dZ W⊤dW = X^\top dZ,\qquad db = \sum_{n=1}^{N} dZ_{n,:},\qquad dX = dZ\,W^\topdW=X⊤dZ,db=n=1∑NdZn,:,dX=dZW⊤
用上面的数字验证(设 dZ=[11]dZ=\begin{bmatrix}1\\1\end{bmatrix}dZ=[11]):
dW=X⊤dZ=[142536][11]=[579],db=[2],dX=dZ W⊤=[0.5−10.20.5−10.2]dW=X^\top dZ=\begin{bmatrix}1&4\\2&5\\3&6\end{bmatrix}\begin{bmatrix}1\\1\end{bmatrix}=\begin{bmatrix}5\\7\\9\end{bmatrix},\qquad db=\begin{bmatrix}2\end{bmatrix},\qquad dX=dZ\,W^\top=\begin{bmatrix}0.5&-1&0.2\\0.5&-1&0.2\end{bmatrix}dW=X⊤dZ=123456[11]=579,db=[2],dX=dZW⊤=[0.50.5−1−10.20.2]
这三条分别对应第 2 节积木表的 ∂y/∂w=x\partial y/\partial w=x∂y/∂w=x、∂y/∂b=1\partial y/\partial b=1∂y/∂b=1、∂y/∂x=w\partial y/\partial x=w∂y/∂x=w——标量手算和矩阵批量,是同一套链式法则的两副面孔。
(4) 更新:每个参数朝负梯度挪一步,第 2 节的 w1: 1.5 → 1.5454 变成对整个矩阵做:
W←W−lr⋅dW,b←b−lr⋅dbW \leftarrow W - lr\cdot dW,\qquad b \leftarrow b - lr\cdot dbW←W−lr⋅dW,b←b−lr⋅db
对应代码 p.data -= self.lr * p.grad(一次更新整个 (3,8) 矩阵)。生命周期串起来:建层(随机 W,b)→ 前向 x@W+b(顺带构图)→ backward() 填梯度 → step() 挪一步 → 循环 4000 次,113 个参数一起被"修正",WWW 从随机乱码变成会看房的好权重。
8. 用框架做一个直观应用:预测房价
空谈框架没意思,拿它做个生活里看得懂的应用:根据面积、卧室数、房龄预测房价(万元)。
- 数据:400 套模拟房,规律
房价 ≈ 20 + 1.2·面积 + 8·卧室 − 1.5·房龄 + 噪声 - 模型:3 层 MLP
Linear(3,8) - Tanh - Linear(8,8) - Tanh - Linear(8,1),113 个参数 - 训练:4000 轮,lr=0.3,loss 从 0.95 → 0.024
- 结果:测试集 80 套 R²=0.963、MAE=7.5 万元
训练完直接问模型"新房子多少钱":
90㎡ 3 室 5 年 -> ≈148 万元 (真实规律 ≈145 万)
140㎡ 4 室 2 年 -> ≈220 万元 (真实规律 ≈217 万)

左边是 loss 下降曲线(前几百轮陡降、后面趋平 = 收敛),右边是预测 vs 真实散点(贴近对角线 = 预测准)。
8.1 训练中,权重是怎么一点点变的(实测快照)
光说"权重被修正"不够直观。我在训练时打了几个"快照",看第一层 Linear(3,8) 的权重(8 个神经元按特征取平均)怎么变:
| epoch | loss | 面积权重均值 | 卧室权重均值 | 房龄权重均值 | 偏置均值 |
|---|---|---|---|---|---|
| 0 | 0.8795 | +0.121 | +0.008 | −0.081 | +0.000 |
| 50 | 0.0340 | +0.110 | +0.064 | −0.105 | +0.003 |
| 500 | 0.0274 | +0.094 | +0.062 | −0.068 | −0.004 |
| 2000 | 0.0287 | +0.178 | +0.105 | −0.044 | −0.002 |
| 3999 | 0.0255 | +0.210 | +0.148 | −0.038 | −0.016 |
三个现象,每一条都能对上理论:
- loss 前 50 轮从 0.88 猛降到 0.034——一开始"错得离谱"、梯度大、修得快;后面 0.027→0.026 几乎不动——梯度趋近 0,这就是收敛(对应左图曲线形态)。
- 面积权重一路往正走(终值 +0.210)、卧室权重也转正(+0.148)——模型"学会"了"面积越大、房间越多 → 越贵"。
- 房龄权重始终为负(终值 −0.038)——模型"学会"了"房龄越大 → 越便宜"。
注意这些权重是标准化后的量,绝对值不大,但正负方向就是模型对每个特征的态度——这正是 5.2 说的"权重 = 神经元对每个输入的关注"。
复现:
demo_weight_snapshots.py(python3 demo_weight_snapshots.py)。
关于 lr / epochs / loss 这三个容易混的概念:
epochs决定"练多久"(4000 轮),lr决定"每步多大"(0.3,不是损失!),loss是训练中算出来的"当前有多差"(0.95 → 0.024)。lr 太大 → 步子太大直接爆掉;太小 → 4000 轮还下不来。
9. 一张图看懂训练时序
train() 里其实就那 5 步在循环:
一条消息一个箭头 = 一次函数调用。训练的本质就是这 5 步循环——前向算值、反向算梯度、更新参数、再来一次。
10. 下一步:接上 Transformer
跑通这套之后,把 MLP 的 WWW 换成 attention 里的 Q/K/V 投影,把 h@Wh@Wh@W 换成 softmax(QK⊤/d)V\text{softmax}(QK^\top/\sqrt{d})Vsoftmax(QK⊤/d)V——你会发现 attention 也只是计算图里的几个节点,反向传播完全一样自动。Transformer 里最核心的 Q/K/V,本质上就是一个 nn.Linear。
从"手算 1 个神经元"到"理解 .backward()“到"能预测房价”,再到"看懂 attention 只是计算图上的几个节点"——这条线走完,你就站在"会看架构"到"真懂训练"的临界点上了。
写在最后
这篇文章帮你建立了什么
- 梯度 = 每层的局部斜率,反向传播 = 沿计算图把斜率连乘(有真实数字验证过,不是背的)。
.backward()不神秘:就是"构图 + 拓扑序反向遍历",60 行代码能复刻。- 框架分层有章法:
Tensor / nn / loss / optim / train五层,和 PyTorch 一一对应。 - 这套东西真的能用:113 个参数、4000 轮,R²=0.963 的房价预测——规模再大,机制不变。
文件清单
| 文件 | 作用 | 怎么跑 |
|---|---|---|
backprop_lab.py | 4 步递进实验(手算→autograd→向量化→PyTorch) | python3 backprop_lab.py |
minidnn/ | 5 层迷你深度学习框架 | 被 demo 引用 |
demo_house_price.py | 房价预测应用 | python3 demo_house_price.py |
demo_weight_snapshots.py | 打印训练中权重快照(8.1 表格的数据来源) | python3 demo_weight_snapshots.py |
tanh_curve.py | 生成 tanh 曲线图 | python3 tanh_curve.py |
backprop_network.drawio | 链式法则示意图(draw.io 打开) | — |
如果你也从"会用框架但心里没底"的状态出发,希望这篇能帮你把那一层窗户纸捅破。下次再看到 .backward(),你看到的不再是一行魔法,而是一张你亲手画过的图。
723



被折叠的 条评论
为什么被折叠?



