深度学习课程 02:神经网络为什么能学习——梯度下降与反向传播

· 约 6 分钟 更新于 2026-09-20 ·
本文目录(13 节)

上一节的训练循环里,真正让模型“学习”的一行是 loss.backward()。它没有搜索答案,也没有理解标签。它做的事情更朴素:沿着计算图从后往前,把损失对每个参数的偏导数算出来。

这篇会手算一个只有两个参数的小模型。数字不难,重点是看清梯度到底从哪里来。

1. 学习就是沿坡下山

设模型只有一个输入:

预测值 y_hat = w * x + b

参数是 w 和 b。如果真实值为 y,用平方误差衡量预测偏差:

loss = (y_hat - y)²

我们想让 loss 变小。梯度就是当前位置最陡的上坡方向,所以更新时要往反方向走:

w_new = w - learning_rate * d(loss)/d(w)
b_new = b - learning_rate * d(loss)/d(b)

这就是梯度下降。神经网络参数很多,但更新规则没有本质变化。

2. 手算一次反向传播

取 x=2、y=5、w=1、b=0:

y_hat = 1 * 2 + 0 = 2
loss = (2 - 5)² = 9

先求损失对预测值的导数:

d(loss)/d(y_hat) = 2 * (y_hat - y) = -6

再看预测值怎样依赖参数:

d(y_hat)/d(w) = x = 2
d(y_hat)/d(b) = 1

链式法则把局部导数串起来:

d(loss)/d(w) = -6 * 2 = -12
d(loss)/d(b) = -6 * 1 = -6

若学习率为 0.1:

w_new = 1 - 0.1 * (-12) = 2.2
b_new = 0 - 0.1 * (-6) = 0.6

新的预测是 2.2 * 2 + 0.6 = 5,刚好命中标签。这个例子过于整齐,真实训练不会一步到位,但它把方向说明白了:负梯度意味着增大参数有助于降低损失。

把局部导数沿计算图相乘

图解:与上文同一数值例子:梯度为负,因此沿负梯度更新会增加 w 和 b。

这个例子的输入固定,更新 w 与 b 都会改变同一个预测。一次恰好命中标签来自数值和学习率的特殊组合,不是梯度下降的普遍保证。如果把学习率改为 1,预测会跨过目标;如果损失写成半平方误差,梯度数值也会减半。比较实现前必须先比较损失定义。

动手核对: 令有限差分步长为 1e-5,分别只扰动 w、只扰动 b,比较中心差分与解析梯度。验收关注绝对误差和浮点类型,而不只打印“通过”。

3. PyTorch 如何记录计算图

import torch

x = torch.tensor(2.0)
y = torch.tensor(5.0)
w = torch.tensor(1.0, requires_grad=True)
b = torch.tensor(0.0, requires_grad=True)

y_hat = w * x + b
loss = (y_hat - y) ** 2
loss.backward()

print(w.grad)  # tensor(-12.)
print(b.grad)  # tensor(-6.)

requires_grad=True 表示需要追踪这个张量参与的运算。前向计算时,PyTorch 动态建立计算图;调用 backward() 后,它从 loss 节点反向遍历,把结果累加到叶子张量的 .grad 中。

所谓叶子张量,可以先理解为“由我们直接创建、需要被优化的参数”。中间结果 y_hat 也在计算图里,但默认不会把它的梯度保留在 .grad。通常我们也不需要。

print(w.is_leaf)      # True
print(y_hat.is_leaf)  # False
print(y_hat.grad)     # None

调试中间梯度时,可以对非叶子张量调用 retain_grad(),但别把它当常规写法。保留每个中间梯度会增加内存占用。

4. 为什么梯度默认会累加

w = torch.tensor(1.0, requires_grad=True)

loss1 = (w * 2 - 5) ** 2
loss1.backward()
print(w.grad)  # -12

loss2 = (w * 2 - 5) ** 2
loss2.backward()
print(w.grad)  # -24

第二次不是重新计算后覆盖,而是在原梯度上相加。这给梯度累积、多个损失共同训练等场景留下了空间。普通 mini-batch 训练则要主动清梯度:

optimizer.zero_grad(set_to_none=True)

set_to_none=True 让梯度回到 None,而不是把原张量填成全 0。很多情况下它更省内存,也能帮助发现“这个参数本轮根本没有拿到梯度”的问题。

5. 多层网络只是链更长

考虑两层网络:

z1 = W1*x + b1
a1 = ReLU(z1)
z2 = W2*a1 + b2
loss = CrossEntropy(z2, y)

要求 loss 对 W1 的梯度,需要依次经过输出层、激活函数和第一层线性变换。链式法则写成:

d(loss)/d(W1)
= d(loss)/d(z2)
* d(z2)/d(a1)
* d(a1)/d(z1)
* d(z1)/d(W1)

矩阵形式看起来更复杂,但仍是局部导数相乘。反向传播的价值是复用已经算过的中间结果,从输出层向前高效传播,不需要为每个参数单独重算完整函数。

6. 激活函数为什么不能省

如果两层之间没有非线性:

W2 * (W1*x + b1) + b2
= (W2*W1)*x + (W2*b1 + b2)

无论叠多少层,最后仍可合并成一个线性变换。ReLU、Sigmoid、GELU 等激活函数打破了这种可合并性,使网络能拟合弯曲边界和更复杂的映射。

ReLU 的导数很直白:输入大于 0 时导数为 1,小于 0 时为 0。它计算便宜,也缓解了深层 Sigmoid 常见的梯度衰减,但并非没有代价。某个神经元长期落在负区间,梯度一直是 0,就可能“死掉”。

7. 用有限差分检查梯度

有限差分适合检查小模型的梯度实现,而不是替代训练中的自动微分。步长过大会引入近似误差,过小又会受浮点精度影响;在 ReLU 的拐点附近,两边差分与某个自动微分约定不同也不一定说明实现错误。

自动微分方便,但自定义算子写错时,需要独立检查。最朴素的方法是有限差分:

f'(w) ≈ [f(w + ε) - f(w - ε)] / (2ε)
def f(value: float) -> float:
    return (value * 2.0 - 5.0) ** 2

epsilon = 1e-4
numeric_grad = (f(1.0 + epsilon) - f(1.0 - epsilon)) / (2 * epsilon)
print(numeric_grad)  # 接近 -12

数值梯度不是训练方法,它慢,而且会受到浮点误差影响。它适合做小规模单元检查:自动微分与数值近似差得很远时,先检查前向公式和张量形状。

实测核对:差分不是另一个训练器

附加实验取 f(x)=(x²-2)²,在 x=0.7、float64 下求导。PyTorch autograd 得到 -4.228;步长 1e-5 的中心差分得到 -4.2279999997063555,绝对差小于 1e-7。完整函数 gradient_check() 位于仓库 examples/blog/run-examples.py,本次结果和环境可下载核对。

这里选择平滑函数是为了隔离自动微分链路。若点落在 ReLU 的零点附近,中心差分可能跨过不可导点;若步长过小,则可能受到浮点消减误差影响。两种情况都不应直接归因为框架算错。数值差分计算成本较高,适合小规模检查而不是替代训练中的反向传播。

8. 停止梯度的正确姿势

有时希望某段计算只产生数值,不参与反向传播:

with torch.no_grad():
    prediction = model(inputs)

推理场景更适合 torch.inference_mode()。如果只想把某个张量从当前计算图分离:

detached = features.detach()

不要写 tensor.data 绕过 autograd。它可能让计算图记录与真实数值不一致,错误往往到后面才出现。

数学补充:Softmax 与交叉熵的梯度为什么简洁

对单个样本,Softmax 把 logits z 转成概率 p:

p_i = exp(z_i) / sum_j exp(z_j)

若真实类别是 k,交叉熵为:

L = -log(p_k)

将两者合并求导,可以得到:

dL/dz_i = p_i - 1(i = k)

也就是说,非真实类别的梯度就是预测概率,真实类别则再减 1。模型越自信地预测错误,修正信号越强。这也是训练时直接传 logits 给 CrossEntropyLoss 的原因:框架可以把 Softmax 和对数运算组合成更稳定的实现。

9. 常见错误

  • 在原地修改参与计算图的张量:可能触发版本检查错误,或让梯度逻辑难以追踪。
  • 把 loss 转成 Python 数字后再 backward:loss.item() 已脱离计算图,只用于日志。
  • 忘记梯度只对浮点或复数张量有意义:整数标签不需要 requires_grad。
  • 误以为 backward() 会更新参数:它只算梯度,更新仍由优化器完成。
  • 重复对同一张计算图反向传播:图默认在反向后释放。确实需要重复时才考虑 retain_graph=True,先问清楚为什么。

10. 练习与面试题

  1. 用 PyTorch 重现手算例子,并手动写参数更新,不使用优化器。
  2. 把平方误差改成绝对误差,比较在 y_hat = y 附近的梯度行为。
  3. 为一个两层网络打印每层参数的梯度范数。
  4. 用有限差分检查其中一个权重的梯度。

为什么反向传播比对每个参数做有限差分高效?

因为它沿计算图复用中间导数,一次反向遍历就能得到所有参数的梯度;有限差分需要逐个扰动参数并重新前向计算。

什么是梯度消失?

梯度在多层链式乘法中不断缩小,前面层几乎收不到更新信号。激活函数、初始化、归一化、残差连接和网络结构都会影响它。

计算图为什么叫“动态”?

PyTorch 在 Python 执行前向代码时即时记录真实发生的运算,控制流改变时图也随之改变。它不是先固定一张完整静态图再运行。

下一篇

梯度算出来不代表训练一定顺利。下一篇处理更现实的问题:loss 震荡、过拟合、梯度爆炸,以及怎样建立排查顺序:训练神经网络的常见问题。

💬 评论

这套方案在哪一步卡住了?也欢迎补充你的实现方式或踩坑经验。

正在连接评论服务,首次访问可能需要几秒…