定位:可选的前置讲。原课要求 Python 必须熟练,PyTorch”最好会但不强制”。如果你写过一些 Python、但没怎么用过 NumPy / PyTorch,建议在第 1 讲之前读这一篇。 假设你已经会:变量、if / for、函数、列表和字典的基本用法。 配套代码:code/prereq1/(1 个模块 + 5 个脚本,每个都在 3 秒内跑完) 本讲的写法:原课没有对应的课次,内容按”读懂并写出本课程代码所需的最少知识”来组织。所有实验数字都来自配套代码的实际输出。
目录
- 0. 导读
- 1. 写模型代码会用到的 Python
- 2. NumPy:一次处理一整个数组
- 3. PyTorch 张量
- 4. 自动微分:backward() 到底做了什么
- 5. 训练循环:同一个模型的三种写法
- 6. 保存、加载与可复现
- 7. 一份调试清单
- 8. 小结、常见误区、练习
0. 导读
0.1 为什么需要这一篇
整门课的代码,剥掉所有细节之后只有三样东西:
- 张量(tensor):多维数组。一句话是一个整数张量(token id),一个模型是一堆浮点张量(参数),一次前向计算是一串张量运算。
- 自动微分(autograd):给定”损失 = 一长串张量运算的结果”,自动算出损失对每个参数的梯度。
- 训练循环:反复执行”取数据 → 前向算损失 → 反向算梯度 → 更新参数”。
一个 70 亿参数的 LLM 和本篇里只有 8100 个参数的玩具模型,用的是同一套代码结构。把这三样东西弄清楚,后面每一讲的代码你都能读懂,出了问题也知道从哪里查起。
0.2 学完这一篇你应该能
- 读懂 NumPy / PyTorch 代码里的形状变换,知道广播、视图、原地修改会在哪里埋下 bug;
- 说清楚计算图和反向传播的原理,自己写一个能和 PyTorch 对上答案的小自动微分引擎;
- 解释为什么深度学习用反向模式而不是前向模式;
- 写出一个标准的 PyTorch 训练循环,并知道
zero_grad、no_grad、.item()、state_dict各自管什么; - 用”初始损失 ≈ log V”、梯度检查、固定种子等手段调试训练代码。
1. 写模型代码会用到的 Python
本课程代码里反复出现、但 Python 入门课不一定讲到的写法:
| 写法 | 例子 | 在本课程里用来做什么 |
|---|---|---|
| 推导式 | {w: i for i, w in enumerate(words)} | 一行建词表(词 → 编号) |
元组拆包、zip | for x, y in zip(a, b) | 同时遍历两组数据 |
| f-string | f"{loss:.3f}" | 打印格式化的数字 |
类与 __init__ | class Vocab: | 把数据和操作它的函数放在一起 |
| “魔法方法” | __len__、__repr__、__add__、__call__ | 让对象支持 len(v)、print(v)、a + b、model(x) |
生成器 yield | def enumerate_sentences(): ... yield s | 按需产生数据,不一次性占满内存 |
装饰器 @ | @torch.no_grad()、@staticmethod | 给函数”加一层外壳” |
上下文管理器 with | with 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 代码里最常见的几个形状( = 批大小, = 序列长度, = 隐藏维度, = 词表大小):
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 为什么需要它
训练就是调参数让损失变小。梯度下降需要损失 对每个参数 的偏导数 。有三种求导办法:
- 数值微分:。简单,但每个参数要算两次损失——70 亿参数就是 140 亿次前向计算;而且有舍入误差(§4.6)。
- 符号微分(像数学课那样推公式):对深层网络,公式会指数级膨胀,且无法处理 if / for 这类控制流。
- 自动微分(automatic differentiation):把程序拆成一个个基本运算,每个运算只需要知道自己的局部导数,再用链式法则组合。结果精确(只有浮点舍入),代价和一次前向计算同一个量级。
4.2 计算图与链式法则
任何计算都可以画成一张图:节点是中间结果,边表示”谁由谁算出”。以 为例():
a ──┐
├─[×]── d = -6 ──┐
b ──┘ ├─[+]── e = 4 ──[²]── L = 16
c ───────────────────┘
反向:从 出发,;每经过一个节点,把”上游传来的梯度”乘以”这个节点的局部导数”,传给它的输入(代码 03 §1):
| 节点 | 局部导数 | 传回的梯度 |
|---|---|---|
| : 8 | ||
| 加法:两个输入的局部导数都是 1 | : 8,: 8 | |
| 乘法:, | : ,: |
PyTorch 算出的 也是 。
要点是顺序:一个节点必须等所有”用到它的下游节点”都把梯度传回来之后,才能继续往上传。把图做一次拓扑排序(每个节点排在它的输入之后),然后倒着走一遍,就保证了这一点。
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 写了求导规则;减法、除法、取负由它们组合而成,不需要额外的规则——这正是自动微分的威力:你写的任何程序,只要由已知的基本运算组成,就自动可导。
为什么是 += 而不是 =? 一个变量可能被用了好几次。,: 作为乘法的两个输入各贡献 3,作为加法的输入贡献 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 前向模式
自动微分还有另一种做法:前向模式。它用”对偶数” (规定 )代替普通的数:由泰勒展开 ,所以把 代入函数,结果的 系数就是导数。它在算函数值的同时算导数,不需要倒着走,实现更简单(tiny_autograd.Dual)。
问题在于一次前向计算只能得到一个方向的导数。代码 03 §4 对一个 6 输入的函数:反向模式 1 次前向 + 1 次反向得到全部 6 个偏导数;前向模式要做 6 次(每次把方向设成一个坐标轴)。两者结果完全一致,但代价差了 倍。
一般地,对 ,前向模式要 遍得到完整的雅可比矩阵,反向模式要 遍。神经网络训练是 = 几十亿(参数)、(损失),反向模式一遍搞定(代价约为前向的 2~3 倍),前向模式要几十亿遍。这就是”反向传播”成为深度学习基石的原因。反向模式的代价是内存:它要保存前向过程中的中间结果,供反向时使用(P3 会算这笔账)。
4.6 梯度检查
自己写求导规则时(比如自定义算子),用有限差分检查:。代码 03 §5 的结果:
| 数值导数与反向传播的差 | |
|---|---|
| 1e-1 | 3.8e-04 |
| 1e-3 | 3.8e-08 |
| 1e-5 | 5.6e-12 |
| 1e-8 | 1.7e-10 |
| 1e-12 | 3.7e-05 |
太大,泰勒展开的高阶项让结果不准(截断误差 ∝ ); 太小, 和 几乎相等,相减后有效数字所剩无几(舍入误差 ∝ )。中间的 最准。梯度检查要用 float64,并且只在小例子上做。
PyTorch 提供了现成的 torch.autograd.gradcheck。代码 04 §6 用 torch.autograd.Function 自己实现了”softmax + 交叉熵”的前向和反向(反向就是第 1 讲推导的 ),gradcheck 通过;故意漏掉”除以 “的版本,gradcheck 立刻报错。
4.7 PyTorch autograd 的用法与坑
代码 04 逐一演示:
- 叶子与 grad_fn:
requires_grad=True的张量是叶子(参数);对它运算得到的张量带有grad_fn(例如TanhBackward0),串成计算图。backward()之后只有叶子的.grad被填上。 - 忘记
zero_grad:用梯度下降拟合 。每步清零时,损失从 2.449 稳步降到 0.053(第 50 步);忘记清零时,损失依次是 2.449、1.215、1.862、1.967、1.085、2.379——.grad里装的是”所有历史梯度之和”,参数冲过了头也停不下来,不收敛。PyTorch 默认累加梯度是有意的设计(显存不够时可以把一个大批次拆成几个小批次,梯度累加后再更新),所以每一步都要显式清零。 no_grad与detach:with torch.no_grad():里的运算不建计算图,用于评估、推理和”更新参数”这一步本身。x.detach()返回一个数值相同、但不再连着计算图的张量,梯度不会流过它——、 时, 而不是 12。.item():把每一步的损失张量直接加起来(total = total + loss),会把每一步的计算图都串起来留在内存里——100 步之后背后挂着 401 个反向节点,并随步数线性增长。记录损失要用loss.item()。- 两条规则:只能对标量直接
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 的更新就是 ( 是学习率)。Adam 为每个参数维护梯度的滑动平均 (动量)和梯度平方的滑动平均 ,更新量约为 ——每个参数的步长按它自己梯度的典型大小缩放。这一点在 §5.4 会变得非常具体。
model.train()/model.eval():切换 dropout 等层的行为;评估时还要配合torch.no_grad()。
5.2 任务:神经网络版的 bigram
代码 05 在第 1 讲的花园世界上,用”前一个词”预测”下一个词”。模型是一张 的分数表 (,共 8100 个参数),第 行是”前一个词是 时,下一个词的 logits”:
这就是输入为 one-hot 向量的 softmax 回归,用 nn.Embedding(V, V) 实现。 初始化为全 0,于是一开始每一行都是均匀分布,初始损失恰好是 (第 1 讲 §5.6 的检查)。
这个模型有一个漂亮的性质:因为输入是 one-hot,损失只依赖于训练集的 bigram 计数矩阵 :
它的最优解是 ——正是第 1 讲”数数”得到的 bigram 最大似然估计(训练损失 1.7150,任何 都不可能更低)。所以下面三种训练方法都应该朝同一个答案走去,我们能精确地看出它们走了多远。作为参照,加 k 平滑(k = 0.1)的 bigram 验证集困惑度是 5.736。
5.3 写法 A 与 B:手推梯度 vs autograd
写法 A(纯 NumPy):第 行的梯度是 ,其中 是 出现的次数——就是把每个位置的 加起来。全批量梯度下降,学习率 50,300 步。
写法 B(PyTorch autograd):同样的算法,但损失写成 -(C * log_softmax(W)).sum() / N,梯度由 backward() 自动算出。
两种写法 300 步后的参数最大差 1.95e-14——只差浮点舍入。autograd 算的就是你手推的那个梯度。
学习率的影响(同样 300 步,全批量梯度下降):
| 学习率 | 训练损失 | 验证困惑度 |
|---|---|---|
| 5 | 2.1806 | 8.983 |
| 20 | 1.8559 | 6.517 |
| 50 | 1.7692 | 5.990 |
| 100 | 1.7469 | 5.878 |
| 400 | 5.0690 | 159.444 |
学习率太小走不动;太大则发散——lr = 400 时损失比初始的 (4.4998)还高。(这里的学习率数值看起来很大,是因为每一行的梯度都被 缩小了,见下一节。)
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 = 50 | 0.0158 | 0.6819 |
| 全批量 GD,lr = 400 | 2.8009 | 0.0486 |
| 小批量 Adam,lr = 0.05 | 0.0067 | 0.0459 |
全批量梯度里,第 行的梯度正比于 :常见词(如 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 MLE | Adam 模型 | 真实分布(I eat 之后) |
|---|---|---|---|
| the | 0.6173 | 0.6240 | 0.6000 |
| my | 0.1596 | 0.1504 | 0.4000 |
| their | 0.0973 | 0.0927 | 0 |
| your | 0.0715 | 0.0845 | 0 |
| our | 0.0542 | 0.0475 | 0 |
模型忠实地学到了训练集的统计: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 的 、)、步数和随机数状态,才能从中断处继续训练(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. 一份调试清单
训练代码出问题时,按这个顺序检查,能解决大部分情况:
- 打印形状:在每一步旁边写下预期的形状,运行时
print(x.shape)对照。广播 bug 最隐蔽。 - 检查初始损失:分类 / 语言模型在训练开始时的损失应约等于 (类别数的对数)。远高于它,说明初始化太大或代码有错(第 1 讲 §5.6 的例子:默认初始化下初始困惑度 315.7,而 )。
- 过拟合一个小批次:只用一两个批次的数据反复训练,损失应该能降到接近 0。降不下去,说明模型、损失或优化代码有 bug。
- 检查梯度:自定义运算用
gradcheck;训练中打印梯度的范数,看有没有全是 0(梯度没传到)或者 inf / nan(数值爆炸)。 - 扫一遍学习率:按 10 倍间隔试几个(如 1e-4、1e-3、1e-2),看损失曲线。§5.3 和 §5.4 的表格就是这样做的。
- 确认三件套:
zero_grad有没有调用;评估时有没有model.eval()+torch.no_grad();记录损失有没有用.item()。
8. 小结、常见误区、练习
8.1 小结
- 向量化:用整个数组的运算代替 for 循环,快两个数量级。注意形状、dtype 和广播。
- 张量 = 数组 + dtype(默认 float32)+ device + 自动求导;
view不复制、要求连续;LLM 的形状套路是 (B, T) → (B, T, d) → (B, T, V)。 - 自动微分 = 计算图 + 局部求导规则 + 按拓扑序倒着用链式法则;梯度沿多条路径相加,所以要清零。手写的一百多行引擎和 PyTorch 结果完全一致。
- 反向模式一遍得到全部参数的梯度,前向模式每遍只得到一个方向导数;参数多、输出是标量时用反向模式。
- 训练循环:取数据 → 前向 →
zero_grad→backward→step。Adam 按参数自适应步长,解决了”常见参数和罕见参数梯度尺度差很多”的问题。 - 可复现与调试:固定种子、单线程;检查形状、初始损失、小批次过拟合、梯度、学习率。
8.2 常见误区
- “
backward()会自动清零梯度”:不会,梯度是累加的,每步都要zero_grad()。 - “
torch.no_grad()只是为了省内存”:它还保证参数更新这一步不被记进计算图;不放在no_grad里直接w -= lr * w.grad会报错(叶子张量不能被原地修改)。 - “
view和reshape一样”:view永远不复制,但要求内存连续;reshape必要时会复制。 - “
torch.from_numpy复制了数据”:没有,它和原 NumPy 数组共享内存。 - “数值微分 h 越小越准”:太小会被舍入误差淹没, 左右最好(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 ,在 和 处检查梯度是否等于 。为什么不需要给 sigmoid 写新的求导规则?
参考答案
s = 1 / (1 + (-x).exp())。:,梯度 0.25;:,梯度 0.104994,等于 (代码验证过)。不需要新规则,因为 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 反向):函数 ,要得到完整的 雅可比矩阵,前向模式和反向模式各需要几遍?如果是 呢?
参考答案
前向模式每遍得到雅可比矩阵的一列(一个输入方向),要 遍;反向模式每遍得到一行(一个输出的梯度),要 遍。:前向 1000 遍,反向 2 遍。:前向 2 遍,反向 1000 遍。神经网络训练是 的极端情况。
练习 5(学习率与频率):在 §5.3 的全批量梯度下降里,为什么 lr = 400 让常见词的行发散,却让罕见词的行学得最好?
参考答案
第 行的损失大约是 ,它的梯度和曲率都和 成正比。梯度下降稳定的条件大致是”学习率 × 曲率 < 2”:对 很大的常见行,lr = 400 远超稳定上限,每一步都冲过头并越摆越大;对 很小的罕见行,lr = 400 乘上很小的曲率,才刚好合适。这就是”不同参数需要不同步长”,Adam 用梯度平方的滑动平均自动估计这个尺度。
练习 6(动手):把 05_training_loop.py 里 train_adam 的学习率分别改成 0.005 和 0.5,训练 2000 步,记录验证困惑度。
参考答案(作者机器上的实际结果)
| 学习率 | 第 500 步 | 第 1000 步 | 第 2000 步 |
|---|---|---|---|
| 0.005 | 9.119 | 6.387 | 5.881 |
| 0.05 | 5.805 | 5.787 | 5.787 |
| 0.5 | 6.377 | 6.305 | 6.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(浮点格式、张量的存储与步长,更深入)