从零手写大模型 · 训练篇 03 - Gradient Descent(梯度下降)

一、上集回顾

上一集我们花了很大篇幅,用"猫吃鱼"的 embedding 向量手推了一遍反向传播——从 loss 出发,一路用链式法则往回传,算出了每一层参数的梯度:

dL/dW2 = [-0.634375, 0]        dL/db2 = -0.875
dL/dW1 = [[-0.4375, -0.875, 0.4375, -0.21875],
          [0, 0, 0, 0]]        dL/db1 = [-0.4375, 0]

但这里有个问题:算出梯度这件事本身,并不会让模型变聪明。 梯度只是告诉你"loss 对这个参数有多敏感、往哪个方向变化会让 loss 变大",它是一份"情报",不是"行动"。

真正让模型参数往"loss 变小"的方向挪动的那一步,叫梯度下降(Gradient Descent)。这集我们就只讲这一件事:拿到梯度之后,参数具体该怎么改。


二、核心直觉:为什么要"沿负梯度方向"走

梯度(gradient)有一个很朴素的性质:它指向的是函数值增大最快的方向。

反过来想:如果你想让 loss 变小,那就应该往梯度的反方向走——这就是"梯度下降"里"下降"两个字的来源。

一个常见的类比是下山:你站在山上(loss 很高的地方),想走到山谷(loss 最小的地方),但你看不到全局地图,只能感知到脚下地面朝哪个方向倾斜最陡(这就是梯度)。最直接的策略就是:每一步都往"最陡的下坡方向"迈一小步,走一步,重新看一眼周围地势,再迈下一步。梯度下降就是把这个过程写成了数学公式,重复很多次。


三、参数更新公式,以及学习率

梯度下降的更新公式非常简单:

新参数 = 旧参数 - 学习率 × 梯度

写成符号:

w_new = w_old - lr × (dL/dw)

这里的 lr(learning rate,学习率)是一个人为设定的小正数,决定"每一步迈多大"。

为什么是减号? 因为梯度指向 loss 增大的方向,减去它,就是往 loss 减小的方向走。

学习率为什么重要? 回到下山的类比:

  • 学习率太小 → 每步迈得太碎,下山下得太慢,可能训练很久 loss 都降不下来多少
  • 学习率太大 → 步子迈得太大,容易一脚从山这边直接跨到山那边,甚至越走越高(loss 不降反升,训练"炸掉")

学习率是训练神经网络时最需要手动调的超参数之一,后面我们实际训练 GPT 的时候还会反复遇到它。


四、猫吃鱼案例:真的做一次参数更新

接着上集的数字继续走。取学习率 lr = 0.1,把 EP02 算出来的梯度,套进更新公式里。

4.1 更新第二层参数

W2_old = [0.5, -0.4]              dL/dW2 = [-0.634375, 0]
W2_new = W2_old - 0.1 × dL/dW2
       = [0.5 - 0.1×(-0.634375), -0.4 - 0.1×0]
       = [0.5634375, -0.4]

b2_old = 0.2                      dL/db2 = -0.875
b2_new = 0.2 - 0.1×(-0.875) = 0.2875

4.2 更新第一层参数

W1_old = [[0.1, 0.2, -0.1, 0.05],
          [0.3, -0.1, 0.2, 0.1 ]]

dL/dW1 = [[-0.4375, -0.875, 0.4375, -0.21875],
          [0, 0, 0, 0]]

W1_new 第一行 = [0.1 - 0.1×(-0.4375), 0.2 - 0.1×(-0.875), -0.1 - 0.1×0.4375, 0.05 - 0.1×(-0.21875)]
             = [0.14375, 0.2875, -0.14375, 0.071875]

W1_new 第二行 = [0.3, -0.1, 0.2, 0.1]  (不变,因为这一行梯度是 0——EP02 讲过,这是 ReLU 把 h2 掐死的后果)
b1_old = [0.1, -0.1]               dL/db1 = [-0.4375, 0]
b1_new = [0.1 - 0.1×(-0.4375), -0.1 - 0.1×0] = [0.14375, -0.1]

4.3 更新之后,loss 真的变小了吗?

拿新参数重新走一遍前向传播,验证效果:

h1_new = 0.14375×1 + 0.2875×2 + (-0.14375)×(-1) + 0.071875×0.5 + 0.14375 = 1.0421875
h2_new = -0.15   (第二行参数没变,这个分量前向传播结果自然也不变)

a_new = ReLU(h_new) = [1.0421875, 0]

y_hat_new = 0.5634375 × 1.0421875 + (-0.4) × 0 + 0.2875 ≈ 0.8747

L_new = (0.8747 - 1)² ≈ 0.0157

对比一下:

更新前: y_hat = 0.5625,  loss ≈ 0.1914
更新后: y_hat = 0.8747,  loss ≈ 0.0157

只走了一步梯度下降,loss 就从 0.1914 降到了 0.0157,预测值也从 0.5625 更靠近目标值 1.0 了。这就是梯度下降在实际发生作用的样子——当然实际训练里学习率通常小得多,一步的效果远没有这么夸张,这里用 lr=0.1 只是为了让效果在手算的例子里看得明显。


五、全量 / 小批量 / 随机梯度下降

上面的例子只有一个样本("猫"这一个 token),但实际训练数据有成千上万个样本,每算一次梯度更新,该用多少样本来算这个梯度?这里有三种常见做法:

  • 全量梯度下降(Batch Gradient Descent):每次更新前,用全部训练样本算出梯度再取平均,然后更新一次参数。梯度估计最准,但数据量一大,算一次就很慢,而且内存可能放不下。
  • 随机梯度下降(Stochastic Gradient Descent, SGD):每次只用一个样本算梯度就更新一次参数。更新很快,但单个样本的梯度噪声很大,loss 下降的路径会很"抖"。
  • 小批量梯度下降(Mini-batch Gradient Descent):每次取一小批样本(比如 32 个、64 个)算平均梯度再更新。这是实际训练中用得最多的方式——在"梯度稳不稳"和"更新快不快"之间取了个平衡点,也更适合 GPU 并行计算。

这几个名字里都带"梯度下降",区别只在于每次更新用多少样本来估计梯度,更新公式本身(w_new = w_old - lr × 梯度)是完全一样的。


六、PyTorch 实现

上面的手算更新,PyTorch 里同样只需要几行代码。承接 EP02 已经算出的 .grad:

import torch

x = torch.tensor([1.0, 2.0, -1.0, 0.5])
W1 = torch.tensor([[0.1, 0.2, -0.1, 0.05],
                    [0.3, -0.1, 0.2, 0.1]], requires_grad=True)
b1 = torch.tensor([0.1, -0.1], requires_grad=True)
W2 = torch.tensor([[0.5, -0.4]], requires_grad=True)
b2 = torch.tensor([0.2], requires_grad=True)
y_true = torch.tensor([1.0])

lr = 0.1

# 前向 + 反向,拿到梯度(上集内容)
h = W1 @ x + b1
a = torch.relu(h)
y_hat = W2 @ a + b2
loss = (y_hat - y_true) ** 2
loss.backward()

# 梯度下降:手动更新一步
with torch.no_grad():
    W1 -= lr * W1.grad
    b1 -= lr * b1.grad
    W2 -= lr * W2.grad
    b2 -= lr * b2.grad

这里 with torch.no_grad() 是必须的——更新参数这个操作本身不应该被 autograd 记录进计算图,不然会把"改参数"这件事也当成前向传播的一部分,搞乱下一轮的梯度计算。

实际写训练代码时,我们不会像上面这样手动一个个减,而是用 PyTorch 封装好的优化器:

optimizer = torch.optim.SGD([W1, b1, W2, b2], lr=0.1)

# ... 前向传播、loss.backward() ...
optimizer.step()       # 等价于上面手动减的那几行
optimizer.zero_grad()  # 清空梯度,给下一轮做准备

torch.optim.SGD 这个名字里的 SGD,就是上一节讲的随机梯度下降——它的 .step() 方法内部做的事情,和我们手动写的 W -= lr * W.grad 本质上完全一样。


七、和系列前面内容的关联

  • 训练篇[EP01](最小二乘法):已经给出过梯度下降的基本直觉(沿梯度反方向调整能让 loss 变小),这一集是把这个直觉在多层网络、结合 EP02 算出的真实梯度上,完整地走了一遍数字化的例子。
  • 训练篇[EP02](反向传播):这一集用到的所有梯度数值,都是直接沿用 EP02 手推出来的结果——反向传播负责"算出梯度",梯度下降负责"拿梯度去更新参数",两者合起来才是训练的完整闭环。

八、下集预告

朴素的梯度下降有个明显的短板:所有参数都用同一个固定学习率、同一种更新方式,遇到 loss 曲面比较复杂、坑坑洼洼的地方(深层网络里很常见),收敛会很慢,甚至会卡住。

下一集,我们来讲实际训练 GPT 时真正在用的优化器——从给梯度下降加上"惯性"的 Momentum,到几乎是现在的标配的 Adam,看看它们各自解决了朴素梯度下降的什么问题。

← 返回训练篇目录