前置讲 P1:Python 与 PyTorch 速成——张量、自动微分、训练循环

Large Language Models: Methods and Applications · 预备知识 1。个人学习讲义与配套实验,按主题整理并持续修订。

课程源码 ZIP ↓

个人整理的学习笔记,非官方讲义;课程来源与整理说明见课程介绍。配套示例未逐一运行验证。

本篇目录

定位:可选的前置讲。原课要求 Python 必须熟练,PyTorch”最好会但不强制”。如果你写过一些 Python、但没怎么用过 NumPy / PyTorch,建议在第 1 讲之前读这一篇。 假设你已经会:变量、if / for、函数、列表和字典的基本用法。 配套代码:code/prereq1/(1 个模块 + 5 个脚本,每个都在 3 秒内跑完) 本讲的写法:原课没有对应的课次,内容按”读懂并写出本课程代码所需的最少知识”来组织。所有实验数字都来自配套代码的实际输出。


目录


0. 导读

0.1 为什么需要这一篇

整门课的代码,剥掉所有细节之后只有三样东西:

  1. 张量(tensor):多维数组。一句话是一个整数张量(token id),一个模型是一堆浮点张量(参数),一次前向计算是一串张量运算。
  2. 自动微分(autograd):给定”损失 = 一长串张量运算的结果”,自动算出损失对每个参数的梯度。
  3. 训练循环:反复执行”取数据 → 前向算损失 → 反向算梯度 → 更新参数”。

一个 70 亿参数的 LLM 和本篇里只有 8100 个参数的玩具模型,用的是同一套代码结构。把这三样东西弄清楚,后面每一讲的代码你都能读懂,出了问题也知道从哪里查起。

0.2 学完这一篇你应该能

  1. 读懂 NumPy / PyTorch 代码里的形状变换,知道广播、视图、原地修改会在哪里埋下 bug;
  2. 说清楚计算图和反向传播的原理,自己写一个能和 PyTorch 对上答案的小自动微分引擎;
  3. 解释为什么深度学习用反向模式而不是前向模式;
  4. 写出一个标准的 PyTorch 训练循环,并知道 zero_grad、no_grad、.item()、state_dict 各自管什么;
  5. 用”初始损失 ≈ log V”、梯度检查、固定种子等手段调试训练代码。

1. 写模型代码会用到的 Python

本课程代码里反复出现、但 Python 入门课不一定讲到的写法:

写法例子在本课程里用来做什么
推导式{w: i for i, w in enumerate(words)}一行建词表(词 → 编号)
元组拆包、zipfor x, y in zip(a, b)同时遍历两组数据
f-stringf"{loss:.3f}"打印格式化的数字
类与 __init__class Vocab:把数据和操作它的函数放在一起
“魔法方法”__len__、__repr__、__add__、__call__让对象支持 len(v)、print(v)、a + b、model(x)
生成器 yielddef enumerate_sentences(): ... yield s按需产生数据,不一次性占满内存
装饰器 @@torch.no_grad()、@staticmethod给函数”加一层外壳”
上下文管理器 withwith torch.no_grad():在一段代码里临时改变行为,离开时自动恢复
if __name__ == "__main__":每个脚本末尾区分”直接运行这个文件”和”被别的文件 import”

其中最值得多看一眼的是魔法方法(运算符重载)。在 Python 里写 a + b,实际调用的是 a.__add__(b);写 model(x),实际调用的是 model.__call__(x)。PyTorch 正是靠这一点让张量的 +、*、@ 看起来像普通数学,同时在背后偷偷记录计算图。§4 会用同样的技巧,自己写一个能记录计算图的”数”。

一个经典的坑:不要用可变对象做默认参数。def f(x, cache=[]) 里的 [] 只在定义函数时创建一次,之后每次调用共用同一个列表。需要默认空列表时写 cache=None,在函数里 cache = cache or []。


2. NumPy:一次处理一整个数组

2.1 为什么要”向量化”

代码 01 §1 计算两个长度一百万的向量的点积:Python for 循环用了 18.1 ms,NumPy 的 a @ b 只用了 0.16 ms,快了约 113 倍(具体倍数取决于机器)。

原因在于:Python 循环的每一步都要经过解释器——取出一个 Python 浮点对象、检查类型、做乘法、生成一个新对象……一百万次。NumPy 把整个计算交给一段编译好的 C 代码,数据在内存里连续存放,还能用上 CPU 的 SIMD 指令。**写模型代码的第一原则:不要对元素写 for 循环,用整个数组的运算代替它。**这叫向量化(vectorization)。GPU 把这个原则推向极致:成千上万个核心同时处理数组的不同部分(P3)。

顺带一提,两个结果在最后几位可能不同:浮点加法不满足结合律,NumPy 用了不同的求和顺序。

2.2 形状与数据类型

每个数组都有形状(shape)和数据类型(dtype)。在 LLM 代码里,形状 (2, 3, 4) 通常读作 (批大小 B, 序列长度 T, 隐藏维度 d)。

dtype 带来的坑(代码 01 §2):

  • NumPy 默认 float64(8 字节),PyTorch 默认 float32(4 字节)。两者混用时要注意转换。
  • int8 只能存 −128~127:[100, 120] + 100 得到 [-56, -36]——溢出后回绕,不报错。
  • float32 只有约 7 位有效数字:1 + 1e-8 在 float32 里就是 1.0。
  • 0.1 + 0.2 == 0.3 是 False(左边是 0.30000000000000004)。比较浮点数用 np.isclose。

2.3 广播

广播(broadcasting)让形状不同的数组直接运算。规则:从最右边的维度开始逐一对齐;两个维度相等、或其中一个是 1、或其中一个不存在,就能广播,大小为 1 的维度被”复制”成另一个的大小(并不真的复制内存)。

  • (2, 3, 4) + (4,) → (2, 3, 4):给每个位置的向量加同一个偏置;
  • (3, 1) - (1, 4) → (3, 4):所有”行 i、列 j”两两相减。

广播让代码很短,也能悄悄制造 bug。代码 01 §3 的例子:标签 y_true 形状 (3,),模型输出 y_pred 形状 (3, 1)(忘了去掉多余的维度)。y_true - y_pred 的形状是 (3, 3)——没有报错,但均方误差算成了 1.42,正确值是 0.02。每做一次运算,都确认结果的形状是你预期的,这是调试数值代码最有效的习惯。

2.4 沿轴归约

z.sum(axis=1) 是”沿第 1 维求和”,即把第 1 维”压扁”,每行剩一个数。keepdims=True 保留被压扁的那一维(长度变成 1),结果可以直接和原数组广播。逐行 softmax 因此可以一行一行地写:

m = z.max(axis=1, keepdims=True)          # (n, 1):每行的最大值
e = np.exp(z - m)                          # (n, V) - (n, 1):广播
p = e / e.sum(axis=1, keepdims=True)       # 每行除以自己的和

2.5 视图与副本

  • 切片得到视图(view):和原数组共享内存。v = a[2:5]; v[0] = 100 会把 a[2] 也改成 100。
  • 花式索引(用整数数组或列表做下标)得到副本:c = a[[2, 3, 4]] 是一份新数据。

花式索引最常见的用法是”取出每个位置正确答案的概率”:logp[np.arange(n), y] 取第 i 行第 y[i] 列。第 1 讲的交叉熵损失就是这样算的(代码 01 §5)。

2.6 随机数

用显式的生成器:rng = np.random.default_rng(42)。同一个种子产生同一串随机数,同一个生成器连续抽样会接着往下走。建议不同用途用不同的生成器(数据打乱一个、初始化一个、采样一个):这样修改其中一处的代码,不会让其他地方的随机数整体错位,实验对比才干净。


3. PyTorch 张量

3.1 张量 = 数组 + dtype + device + 梯度

PyTorch 的张量(torch.Tensor)和 NumPy 数组几乎是同一个东西,API 也很像(x.shape、x.sum(dim=1)、x @ y),多出来的是:

  • 默认 dtype 是 float32;整数张量默认 int64(torch.long),token id 一般用它;
  • device:张量可以放在 CPU 或 GPU 上(x.to("cuda"))。参与同一个运算的张量必须在同一个设备上;
  • 自动求导:设了 requires_grad=True 的张量会被追踪(§4)。

和 NumPy 互相转换时要分清复制和共享(代码 02 §2):torch.tensor(a) 复制一份;torch.from_numpy(a) 和 t.numpy() 共享内存,改一边另一边也变。from_numpy 还会保留 NumPy 的 float64。

3.2 改变形状:view、reshape、transpose

张量在内存里是一维连续存放的,形状只是”怎样解读这段内存”(加上每一维的步长 stride)。

  • x.view(3, 4):换一种解读方式,不复制。要求内存是连续的。-1 表示”自动算出这一维”。
  • x.t() / x.transpose(1, 2):交换两个维度,也不复制,只改步长——于是结果不再连续,再调用 .view 会报错。
  • x.reshape(...):能 view 就 view,不能就复制一份;或者先 .contiguous() 再 view。

3.3 语言模型里的形状套路

代码 02 §4 演示了 LLM 代码里最常见的几个形状(BB = 批大小,TT = 序列长度,dd = 隐藏维度,VV = 词表大小):

token id            ids                 (B, T)        整数
查嵌入表            E[ids]              (B, T, d)     用整数张量做下标 = 查表
输出层              h @ E.T             (B, T, V)     @ 只作用在最后两维,前面的维度当作"批"
拆成多头注意力      h.view(B,T,H,d/H).transpose(1,2)  (B, H, T, d/H)   (第 2 讲)
算损失前摊平        logits.reshape(B*T, V),targets.reshape(B*T)
沿词表归一化        torch.softmax(logits, dim=-1)

读别人的模型代码时,在每一行旁边写下张量的形状,是最快的理解方式。

3.4 原地操作、.item() 与精度

  • 方法名以下划线结尾的是原地操作:x.add_(1) 直接修改 x;x.add(1) 返回新张量。原地操作省内存,但可能破坏反向传播需要的中间结果,PyTorch 发现时会报错。
  • loss.sum() 仍然是一个 0 维张量;.item() 才把它变成 Python 数字。记录损失用 .item()(原因见 §4.7)。
  • float32 的累加误差(代码 02 §5):一千万个 0.1 求和,float32 得到 999999.125,float64 得到 1000000.0000;用 float32 逐个累加 10 万次 0.0001,得到 10.00631 而不是 10。规模越大误差越明显——这是混合精度训练坚持用 float32 做累加的原因(P3)。

4. 自动微分:backward() 到底做了什么

4.1 为什么需要它

训练就是调参数让损失变小。梯度下降需要损失 LL 对每个参数 θi\theta_i 的偏导数 ∂L/∂θi\partial L / \partial \theta_i。有三种求导办法:

  • 数值微分:L(θ+hei)−L(θ−hei)2h\frac{L(\theta + h e_i) - L(\theta - h e_i)}{2h}。简单,但每个参数要算两次损失——70 亿参数就是 140 亿次前向计算;而且有舍入误差(§4.6)。
  • 符号微分(像数学课那样推公式):对深层网络,公式会指数级膨胀,且无法处理 if / for 这类控制流。
  • 自动微分(automatic differentiation):把程序拆成一个个基本运算,每个运算只需要知道自己的局部导数,再用链式法则组合。结果精确(只有浮点舍入),代价和一次前向计算同一个量级。

4.2 计算图与链式法则

任何计算都可以画成一张图:节点是中间结果,边表示”谁由谁算出”。以 L=(a⋅b+c)2L = (a \cdot b + c)^2 为例(a=2,b=−3,c=10a = 2, b = -3, c = 10):

a ──┐
    ├─[×]── d = -6 ──┐
b ──┘                ├─[+]── e = 4 ──[²]── L = 16
c ───────────────────┘

反向:从 LL 出发,∂L/∂L=1\partial L/\partial L = 1;每经过一个节点,把”上游传来的梯度”乘以”这个节点的局部导数”,传给它的输入(代码 03 §1):

节点局部导数传回的梯度
L=e2L = e^2∂L/∂e=2e=8\partial L/\partial e = 2e = 8ee: 8
e=d+ce = d + c加法:两个输入的局部导数都是 1dd: 8,cc: 8
d=a⋅bd = a \cdot b乘法:∂d/∂a=b\partial d/\partial a = b,∂d/∂b=a\partial d/\partial b = aaa: 8×(−3)=−248 \times (-3) = -24,bb: 8×2=168 \times 2 = 16

PyTorch 算出的 ∂L/∂a,∂L/∂b,∂L/∂c\partial L/\partial a, \partial L/\partial b, \partial L/\partial c 也是 −24,16,8-24, 16, 8。

要点是顺序:一个节点必须等所有”用到它的下游节点”都把梯度传回来之后,才能继续往上传。把图做一次拓扑排序(每个节点排在它的输入之后),然后倒着走一遍,就保证了这一点。

4.3 自己写一个:tiny_autograd.Value

tiny_autograd.py 只有一百多行。核心是一个 Value 类:

class Value:
    def __init__(self, data, _parents=(), op=""):
        self.data, self.grad = data, 0.0
        self._parents = _parents          # 我是由谁算出来的
        self._backward = lambda: None     # 怎样把我的梯度传给它们

    def __mul__(self, other):
        out = Value(self.data * other.data, (self, other), "*")
        def _backward():
            self.grad  += other.data * out.grad   # ∂(ab)/∂a = b
            other.grad += self.data * out.grad    # ∂(ab)/∂b = a
        out._backward = _backward
        return out

每个运算做两件事:算出结果,并”留下一张纸条”说明怎样把梯度传回去。backward() 先拓扑排序,再倒着调用每个节点的 _backward。引擎只为 +、×、**常数、exp、log、tanh、relu 写了求导规则;减法、除法、取负由它们组合而成,不需要额外的规则——这正是自动微分的威力:你写的任何程序,只要由已知的基本运算组成,就自动可导。

为什么是 += 而不是 =? 一个变量可能被用了好几次。y=x⋅x+xy = x \cdot x + x,x=3x = 3:xx 作为乘法的两个输入各贡献 3,作为加法的输入贡献 1,合计 dy/dx=7=2x+1dy/dx = 7 = 2x + 1(代码 03 §2)。每条路径的梯度都要加起来。这个设计的副作用是:如果不清零就再做一次反向传播,梯度会变成 14——这正是 PyTorch 里忘记 zero_grad() 的 bug(PyTorch 里同样调用两次 backward 也得到 14)。

实现细节:PyTorch 只把梯度累加到叶子张量(参数)上,中间结果的梯度用完就丢。我们的引擎也在每次反向传播前把中间节点的梯度清零,行为和 PyTorch 一致。

4.4 和 PyTorch 对答案

代码 03 §3 用两种方式计算同一个 2-3-1 小网络(2 个输入、3 个 tanh 隐藏单元、1 个输出,平方误差损失)的梯度:手写引擎和 PyTorch(float64)。13 个参数的梯度完全一致(最大差异 0)。

手写引擎的计算图有 51 个节点——每个标量加法、乘法、tanh 都是一个节点。PyTorch 做的是同一件事,只是节点是整个张量的运算(一次矩阵乘法是一个节点),每个节点的局部求导规则用 C++/CUDA 高效实现。看懂这一百多行,backward() 就不再是魔法了。

4.5 反向模式 vs 前向模式

自动微分还有另一种做法:前向模式。它用”对偶数” a+bεa + b\varepsilon(规定 ε2=0\varepsilon^2 = 0)代替普通的数:由泰勒展开 f(x+ε)=f(x)+f′(x)εf(x + \varepsilon) = f(x) + f'(x)\varepsilon,所以把 x+1⋅εx + 1\cdot\varepsilon 代入函数,结果的 ε\varepsilon 系数就是导数。它在算函数值的同时算导数,不需要倒着走,实现更简单(tiny_autograd.Dual)。

问题在于一次前向计算只能得到一个方向的导数。代码 03 §4 对一个 6 输入的函数:反向模式 1 次前向 + 1 次反向得到全部 6 个偏导数;前向模式要做 6 次(每次把方向设成一个坐标轴)。两者结果完全一致,但代价差了 nn 倍。

一般地,对 f:Rn→Rmf: \mathbb{R}^n \to \mathbb{R}^m,前向模式要 nn 遍得到完整的雅可比矩阵,反向模式要 mm 遍。神经网络训练是 nn = 几十亿(参数)、m=1m = 1(损失),反向模式一遍搞定(代价约为前向的 2~3 倍),前向模式要几十亿遍。这就是”反向传播”成为深度学习基石的原因。反向模式的代价是内存:它要保存前向过程中的中间结果,供反向时使用(P3 会算这笔账)。

4.6 梯度检查

自己写求导规则时(比如自定义算子),用有限差分检查:f(x+h)−f(x−h)2h\frac{f(x + h) - f(x - h)}{2h}。代码 03 §5 的结果:

hh数值导数与反向传播的差
1e-13.8e-04
1e-33.8e-08
1e-55.6e-12
1e-81.7e-10
1e-123.7e-05

hh 太大,泰勒展开的高阶项让结果不准(截断误差 ∝ h2h^2);hh 太小,f(x+h)f(x+h) 和 f(x−h)f(x-h) 几乎相等,相减后有效数字所剩无几(舍入误差 ∝ 1/h1/h)。中间的 h≈10−5h \approx 10^{-5} 最准。梯度检查要用 float64,并且只在小例子上做。

PyTorch 提供了现成的 torch.autograd.gradcheck。代码 04 §6 用 torch.autograd.Function 自己实现了”softmax + 交叉熵”的前向和反向(反向就是第 1 讲推导的 (p−y)/N(p - y)/N),gradcheck 通过;故意漏掉”除以 NN“的版本,gradcheck 立刻报错。

4.7 PyTorch autograd 的用法与坑

代码 04 逐一演示:

  1. 叶子与 grad_fn:requires_grad=True 的张量是叶子(参数);对它运算得到的张量带有 grad_fn(例如 TanhBackward0),串成计算图。backward() 之后只有叶子的 .grad 被填上。
  2. 忘记 zero_grad:用梯度下降拟合 y=2x+1y = 2x + 1。每步清零时,损失从 2.449 稳步降到 0.053(第 50 步);忘记清零时,损失依次是 2.449、1.215、1.862、1.967、1.085、2.379——.grad 里装的是”所有历史梯度之和”,参数冲过了头也停不下来,不收敛。PyTorch 默认累加梯度是有意的设计(显存不够时可以把一个大批次拆成几个小批次,梯度累加后再更新),所以每一步都要显式清零。
  3. no_grad 与 detach:with torch.no_grad(): 里的运算不建计算图,用于评估、推理和”更新参数”这一步本身。x.detach() 返回一个数值相同、但不再连着计算图的张量,梯度不会流过它——c=b.detach()⋅ac = b.\text{detach}() \cdot a、b=3ab = 3a 时,dc/da=6dc/da = 6 而不是 12。
  4. .item():把每一步的损失张量直接加起来(total = total + loss),会把每一步的计算图都串起来留在内存里——100 步之后背后挂着 401 个反向节点,并随步数线性增长。记录损失要用 loss.item()。
  5. 两条规则:只能对标量直接 backward()(向量要给权重,y.backward(torch.ones_like(y)) 等价于 y.sum().backward());同一张图默认只能反向一次(中间结果已被释放,需要时用 retain_graph=True)。

5. 训练循环:同一个模型的三种写法

5.1 一个训练循环的解剖

model = MyModel()                                     # nn.Module:登记所有参数
opt = torch.optim.Adam(model.parameters(), lr=1e-3)   # 优化器:知道要更新哪些参数、怎样更新
for step in range(num_steps):
    x, y = get_batch()                                # 1. 取一批数据
    loss = F.cross_entropy(model(x), y)               # 2. 前向:算损失
    opt.zero_grad()                                   # 3. 清零上一步的梯度
    loss.backward()                                   # 4. 反向:算梯度
    opt.step()                                        # 5. 更新参数
  • nn.Module:在 __init__ 里把子层或 nn.Parameter 赋值给 self.xxx,PyTorch 就会自动登记它们;model.parameters() 能找到全部参数,model(x) 调用 forward(x)。
  • 优化器:SGD 的更新就是 θ←θ−η ∇θL\theta \leftarrow \theta - \eta\, \nabla_\theta L(η\eta 是学习率)。Adam 为每个参数维护梯度的滑动平均 mm(动量)和梯度平方的滑动平均 vv,更新量约为 η⋅m/(v+ϵ)\eta \cdot m / (\sqrt{v} + \epsilon)——每个参数的步长按它自己梯度的典型大小缩放。这一点在 §5.4 会变得非常具体。
  • model.train() / model.eval():切换 dropout 等层的行为;评估时还要配合 torch.no_grad()。

5.2 任务:神经网络版的 bigram

代码 05 在第 1 讲的花园世界上,用”前一个词”预测”下一个词”。模型是一张 V×VV \times V 的分数表 WW(V=90V = 90,共 8100 个参数),第 aa 行是”前一个词是 aa 时,下一个词的 logits”:

p(b∣a)=softmax(W[a])bp(b \mid a) = \text{softmax}(W[a])_b

这就是输入为 one-hot 向量的 softmax 回归,用 nn.Embedding(V, V) 实现。WW 初始化为全 0,于是一开始每一行都是均匀分布,初始损失恰好是 log⁡V=4.4998\log V = 4.4998(第 1 讲 §5.6 的检查)。

这个模型有一个漂亮的性质:因为输入是 one-hot,损失只依赖于训练集的 bigram 计数矩阵 C[a,b]C[a, b]:

L(W)=−1N∑a,bC[a,b] log⁡softmax(W[a])b\mathcal{L}(W) = -\frac{1}{N}\sum_{a, b} C[a, b]\, \log \text{softmax}(W[a])_b

它的最优解是 softmax(W[a])=C[a]/∑bC[a,b]\text{softmax}(W[a]) = C[a] / \sum_b C[a, b]——正是第 1 讲”数数”得到的 bigram 最大似然估计(训练损失 1.7150,任何 WW 都不可能更低)。所以下面三种训练方法都应该朝同一个答案走去,我们能精确地看出它们走了多远。作为参照,加 k 平滑(k = 0.1)的 bigram 验证集困惑度是 5.736。

5.3 写法 A 与 B:手推梯度 vs autograd

写法 A(纯 NumPy):第 aa 行的梯度是 (na⋅softmax(W[a])−C[a])/N(n_a \cdot \text{softmax}(W[a]) - C[a]) / N,其中 nan_a 是 aa 出现的次数——就是把每个位置的 p−yp - y 加起来。全批量梯度下降,学习率 50,300 步。

写法 B(PyTorch autograd):同样的算法,但损失写成 -(C * log_softmax(W)).sum() / N,梯度由 backward() 自动算出。

两种写法 300 步后的参数最大差 1.95e-14——只差浮点舍入。autograd 算的就是你手推的那个梯度。

学习率的影响(同样 300 步,全批量梯度下降):

学习率训练损失验证困惑度
52.18068.983
201.85596.517
501.76925.990
1001.74695.878
4005.0690159.444

学习率太小走不动;太大则发散——lr = 400 时损失比初始的 log⁡V\log V(4.4998)还高。(这里的学习率数值看起来很大,是因为每一行的梯度都被 na/Nn_a / N 缩小了,见下一节。)

5.4 写法 C:小批量 + Adam,以及”罕见词学得慢”

写法 C 是标准写法:BigramLM(nn.Module) + 每步随机抽 512 个 (前一个词, 下一个词) 对 + Adam(学习率 0.05)+ 每 500 步记录训练/验证损失。验证困惑度:第 0 步 90.000,第 500 步 5.805,第 2000 步 5.787——非常接近加 k 平滑 bigram 的 5.736。

为什么 Adam 好这么多?把”前一个词”按出现次数分组,看每一行离 MLE 还有多远(KL 散度,越小越接近):

方法最常见的 10 个前一个词13 个罕见的前一个词(≤ 300 次)
全批量 GD,lr = 500.01580.6819
全批量 GD,lr = 4002.80090.0486
小批量 Adam,lr = 0.050.00670.0459

全批量梯度里,第 aa 行的梯度正比于 na/Nn_a / N:常见词(如 the 出现了两万多次)的行梯度大,罕见词的行梯度小。用同一个学习率:lr = 50 时常见行学好了,罕见行还差得远;lr = 400 时罕见行学会了,常见行却发散了。没有一个全局学习率能同时照顾两者。Adam 用每个参数自己的梯度大小来缩放步长,常见行和罕见行都学得不错。

这不是玩具里才有的问题。LLM 的词表里,常见 token 和罕见 token 的频率相差好几个数量级,嵌入矩阵的不同行、网络的不同参数,梯度尺度差别都很大。这是 LLM 训练几乎都用 Adam 系优化器的重要原因(第 13 讲会细讲 AdamW、学习率预热与衰减)。

Adam 的学习率仍然需要调:同样 2000 步,lr = 0.005 时验证困惑度 5.881(还没走到),lr = 0.5 时停在 6.3 左右(步子太大,在最优点附近来回跳),lr = 0.05 最好(5.787)。

5.5 bigram 学到了什么、学不到什么

“eat” 之后的下一个词(代码 05):

下一个词bigram MLEAdam 模型真实分布(I eat 之后)
the0.61730.62400.6000
my0.15960.15040.4000
their0.09730.09270
your0.07150.08450
our0.05420.04750

模型忠实地学到了训练集的统计:eat 之后 62% 是 the,其余是各种物主代词。但 bigram 只看前一个词,所以它给 I eat 之后的 their 也分了 9% 的概率——真实语言里,I 之后只能是 my。训练算法再好,模型看不到的信息也学不会。这就是第 1 讲用更长的上下文、第 2 讲用注意力的原因。


6. 保存、加载与可复现

  • 保存与加载:torch.save(model.state_dict(), "model.pt") 保存全部参数(一个”参数名 → 张量”的字典);加载时先建一个同样结构的模型,再 model.load_state_dict(torch.load("model.pt"))。代码 05 验证了加载后的模型输出和原模型完全相同。完整的训练检查点还要保存优化器状态(Adam 的 mm、vv)、步数和随机数状态,才能从中断处继续训练(P3 会实现)。
  • 可复现:固定所有随机种子(torch.manual_seed、NumPy 和 Python 的生成器),CPU 上用单线程(torch.set_num_threads(1),多线程求和的顺序可能不同)。代码 05 用种子 0 训练两次,验证损失都是 1.758645;换成种子 1 是 1.758195。在 GPU 上,有些运算默认不保证确定性,需要 torch.use_deterministic_algorithms(True) 等设置,代价是可能更慢。
  • 为什么在意可复现:比较两个实验设置时,如果同一个设置跑两次结果都不一样,你就无法判断差别来自设置还是来自随机性。本课程所有脚本都固定了种子,重复运行逐字节相同。

7. 一份调试清单

训练代码出问题时,按这个顺序检查,能解决大部分情况:

  1. 打印形状:在每一步旁边写下预期的形状,运行时 print(x.shape) 对照。广播 bug 最隐蔽。
  2. 检查初始损失:分类 / 语言模型在训练开始时的损失应约等于 log⁡V\log V(类别数的对数)。远高于它,说明初始化太大或代码有错(第 1 讲 §5.6 的例子:默认初始化下初始困惑度 315.7,而 V=90V = 90)。
  3. 过拟合一个小批次:只用一两个批次的数据反复训练,损失应该能降到接近 0。降不下去,说明模型、损失或优化代码有 bug。
  4. 检查梯度:自定义运算用 gradcheck;训练中打印梯度的范数,看有没有全是 0(梯度没传到)或者 inf / nan(数值爆炸)。
  5. 扫一遍学习率:按 10 倍间隔试几个(如 1e-4、1e-3、1e-2),看损失曲线。§5.3 和 §5.4 的表格就是这样做的。
  6. 确认三件套:zero_grad 有没有调用;评估时有没有 model.eval() + torch.no_grad();记录损失有没有用 .item()。

8. 小结、常见误区、练习

8.1 小结

  1. 向量化:用整个数组的运算代替 for 循环,快两个数量级。注意形状、dtype 和广播。
  2. 张量 = 数组 + dtype(默认 float32)+ device + 自动求导;view 不复制、要求连续;LLM 的形状套路是 (B, T) → (B, T, d) → (B, T, V)。
  3. 自动微分 = 计算图 + 局部求导规则 + 按拓扑序倒着用链式法则;梯度沿多条路径相加,所以要清零。手写的一百多行引擎和 PyTorch 结果完全一致。
  4. 反向模式一遍得到全部参数的梯度,前向模式每遍只得到一个方向导数;参数多、输出是标量时用反向模式。
  5. 训练循环:取数据 → 前向 → zero_grad → backward → step。Adam 按参数自适应步长,解决了”常见参数和罕见参数梯度尺度差很多”的问题。
  6. 可复现与调试:固定种子、单线程;检查形状、初始损失、小批次过拟合、梯度、学习率。

8.2 常见误区

  • “backward() 会自动清零梯度”:不会,梯度是累加的,每步都要 zero_grad()。
  • “torch.no_grad() 只是为了省内存”:它还保证参数更新这一步不被记进计算图;不放在 no_grad 里直接 w -= lr * w.grad 会报错(叶子张量不能被原地修改)。
  • “view 和 reshape 一样”:view 永远不复制,但要求内存连续;reshape 必要时会复制。
  • “torch.from_numpy 复制了数据”:没有,它和原 NumPy 数组共享内存。
  • “数值微分 h 越小越准”:太小会被舍入误差淹没,10−510^{-5} 左右最好(float64)。
  • “学习率是个小细节”:§5.3 的同一个模型,学习率差 20 倍,结果从”学不会”到”发散”。

8.3 练习

练习 1(广播):形状 (5, 1, 3) 和 (4, 1) 的数组相加,结果是什么形状?(B, T, d) 和 (B, d) 能直接相加吗?

参考答案

从右对齐:(5, 1, 3) 与 (_, 4, 1),最后一维 3 vs 1 → 3,中间 1 vs 4 → 4,第一维 5 vs 不存在 → 5,结果 (5, 4, 3)(代码验证过)。(B, T, d) 和 (B, d):右对齐后是 (B, T, d) vs (_, B, d),中间一维 T vs B,除非 T = B 或其中一个是 1,否则报错——即使 T 恰好等于 B,结果也是错的(把批次维当成了时间维)。正确写法是 x + y[:, None, :],即把 y 变成 (B, 1, d)。

练习 2(扩展自动微分引擎):用 Value 已有的运算写出 sigmoid σ(x)=1/(1+e−x)\sigma(x) = 1 / (1 + e^{-x}),在 x=0x = 0 和 x=2x = 2 处检查梯度是否等于 σ(x)(1−σ(x))\sigma(x)(1 - \sigma(x))。为什么不需要给 sigmoid 写新的求导规则?

参考答案

s = 1 / (1 + (-x).exp())。x=0x = 0:σ=0.5\sigma = 0.5,梯度 0.25;x=2x = 2:σ=0.880797\sigma = 0.880797,梯度 0.104994,等于 σ(1−σ)\sigma(1 - \sigma)(代码验证过)。不需要新规则,因为 sigmoid 由取负、exp、加法、除法(= 乘以 −1 次方)组成,每个都有求导规则,链式法则自动把它们拼起来。真实框架仍然会给 sigmoid 单独写规则,那是为了速度和数值稳定,不是为了正确性。

练习 3(梯度累积):显存只放得下批大小 64,但你想用批大小 256 训练。利用”梯度默认累加”写出训练循环。损失要不要做什么修改?

参考答案
opt.zero_grad()
for micro in range(4):                       # 4 个大小为 64 的小批次
    x, y = get_batch(64)
    loss = F.cross_entropy(model(x), y) / 4  # 除以小批次个数
    loss.backward()                          # 梯度累加进 .grad
opt.step()

cross_entropy 默认对批次取平均,4 个小批次的梯度直接相加会是”平均梯度”的 4 倍,所以每个损失要除以 4,才和一次性用 256 个样本的梯度相同。这正是 PyTorch 选择”累加”语义的原因之一。

练习 4(前向 vs 反向):函数 f:R1000→R2f: \mathbb{R}^{1000} \to \mathbb{R}^{2},要得到完整的 2×10002 \times 1000 雅可比矩阵,前向模式和反向模式各需要几遍?如果是 R2→R1000\mathbb{R}^{2} \to \mathbb{R}^{1000} 呢?

参考答案

前向模式每遍得到雅可比矩阵的一列(一个输入方向),要 nn 遍;反向模式每遍得到一行(一个输出的梯度),要 mm 遍。R1000→R2\mathbb{R}^{1000} \to \mathbb{R}^2:前向 1000 遍,反向 2 遍。R2→R1000\mathbb{R}^2 \to \mathbb{R}^{1000}:前向 2 遍,反向 1000 遍。神经网络训练是 m=1m = 1 的极端情况。

练习 5(学习率与频率):在 §5.3 的全批量梯度下降里,为什么 lr = 400 让常见词的行发散,却让罕见词的行学得最好?

参考答案

第 aa 行的损失大约是 naN⋅(这一行的交叉熵)\frac{n_a}{N}\cdot(\text{这一行的交叉熵}),它的梯度和曲率都和 na/Nn_a / N 成正比。梯度下降稳定的条件大致是”学习率 × 曲率 < 2”:对 na/Nn_a / N 很大的常见行,lr = 400 远超稳定上限,每一步都冲过头并越摆越大;对 na/Nn_a / N 很小的罕见行,lr = 400 乘上很小的曲率,才刚好合适。这就是”不同参数需要不同步长”,Adam 用梯度平方的滑动平均自动估计这个尺度。

练习 6(动手):把 05_training_loop.py 里 train_adam 的学习率分别改成 0.005 和 0.5,训练 2000 步,记录验证困惑度。

参考答案(作者机器上的实际结果)
学习率第 500 步第 1000 步第 2000 步
0.0059.1196.3875.881
0.055.8055.7875.787
0.56.3776.3056.306

lr = 0.005 太慢,2000 步还没走到;lr = 0.5 很快就降下来,但卡在 6.3 左右——小批量的梯度有噪声,大步长让参数在最优点附近来回跳,无法精细收敛。实践中常用”先大后小”的学习率调度(预热 + 衰减)兼顾两者(第 13 讲)。

练习 7(思考):评估模型时忘了写 torch.no_grad(),结果会错吗?忘了 model.eval() 呢?

参考答案

忘了 no_grad:数值结果不会错,但会建计算图、占用大量内存(大模型评估时可能直接显存不足),也更慢。忘了 model.eval():如果模型里有 dropout 或 batch normalization,它们会按训练模式工作(随机丢弃神经元、用当前批次的统计量),评估结果会带随机性、偏低;本篇的 bigram 模型没有这些层,所以没有区别。两者都要写。

8.4 延伸阅读

  • PyTorch 官方教程 Learn the Basics(张量、autograd、nn.Module、优化循环)
  • NumPy 官方文档 Broadcasting
  • Karpathy, micrograd(一个和本篇 tiny_autograd 思路相同的小引擎,附讲解视频)
  • Baydin et al. (2018), Automatic Differentiation in Machine Learning: a Survey(前向 / 反向模式的系统介绍)
  • Kingma & Ba (2015), Adam: A Method for Stochastic Optimization(第 13 讲的阅读材料)
  • 本仓库 CMU10-423 前置讲 P1(浮点格式、张量的存储与步长,更深入)

8.5 接下来

  • 如果机器学习的基本概念(最大似然、交叉熵、过拟合、正则化)和 NLP 的基本事实(Zipf 定律、词向量)还不熟,读 前置讲 P2。
  • 否则直接进入 第 1 讲:本篇的 bigram softmax 回归,就是第 1 讲”固定窗口神经语言模型”的最简单特例(窗口 = 1)。
← 返回课程目录