Skip to content

第 6 章 用神经网络重写 bigram ​

计数法已经能得到 bigram 模型,为什么还要写神经网络?这一章不是为了用更复杂的代码胜过相对频率,而是用同一个简单任务接通张量、自动求导、损失与训练。确认两种写法到底哪里相同、哪里不同,后面增加上下文时就不容易把实现变化误当成模型能力变化。

6.1 一批训练样本是什么形状 ​

把每个字符对拆成输入 ID 与标签 ID。若一批有 B 对,xs 和 ys 的形状都为 (B,),数据类型为 torch.long。例如名字 aba 的四对样本可写为输入 [0,1,2,1],标签 [1,2,1,0],这里词表中的 a=1,b=2。

不直接把 ID 当连续数值,而把每个 ID 改成长度 V 的 one-hot 向量:该位置为 1,其他位置为 0。于是 xenc 的形状为 (B,V)。权重矩阵 W 的形状为 (V,V),第一维对应当前字符,第二维对应可能的下一字符。

python
xenc = F.one_hot(xs, num_classes=V).to(W.dtype)
logits = xenc @ W

这里的 @ 是矩阵乘法,结果形状为 (B,V)。对第 r 条输入,

zrj=∑i=0V−1xencriWij=Wxr,j.

因为求和只有一个位置不为零,所以乘法实际就是选行。可以直接写 logits=W[xs]。省掉的是临时 one-hot 矩阵和相应乘法,模型表达的分布完全没变。这是官方 bigram E04 的答案,本书测试同时核对了前向值和重复索引的梯度累加。

原 notebook 先建立 (27,1) 权重,演示一个神经元,然后再建立 (27,27) 权重。若在只有一列时按行做 softmax,每行唯一概率都为 1,无法完成 27 类分类。完整版本必须有 V 个输出打分;不要把中途演示单元当成独立完成的模型。固定 bigram notebook

6.2 从任意打分到概率 ​

网络输出 zrj 称为 logit,是任意实数,没有 “必须非负” 的限制。先取指数,再除以一行指数和:

prj=exp⁡(zrj)∑k=0V−1exp⁡(zrk).

这就是 softmax。指数保证分子为正,归一化保证每行和为 1。所有类别相互竞争:提高一个打分,会让它的概率增加,同时压低其他类别的概率。

课程把指数结果叫作 “counts”,便于与计数表对照。这些数一般不是整数,也不是模型真正见过的频次,而是可学习的正权重。称 logit 为 “log-count” 是这个类比下的解释,不要把它理解成唯一可辨识的真实计数。

给一行所有 logits 加同一常数,分布不变,因此存在多组 W 表示同一个模型。特别地,对有正数计数的行,令

Wij=log⁡(Nij+α),

再做 softmax,就精确得到加 α 平滑后的计数分布。我们的测试对这一恒等式直接断言。未经平滑而存在零概率的最大似然表,不能用全为有限数的 logits 精确表示,只能让相应打分趋向负无穷逐渐逼近。

6.3 用正确标签选出概率,再求平均 ​

若 probs 形状为 (B,V),ys 形状为 (B,),表达式

python
probs[torch.arange(B), ys]

选的是每行各自真实标签的概率,得到 (B,)。它不是选出全部行与全部标签的笛卡尔组合。再取对数、取负、求均值,就是上一章的平均 NLL。

但实际运行推荐直接用

python
loss = F.cross_entropy(logits, ys)

这里传 logits,不要先传 softmax 后的概率。对整数类别目标,默认平均交叉熵正是上述 NLL。在数学上可以写成

L=1B∑r[log⁡∑jezrj−zr,yr].

稳定计算令 mr=maxjzrj,把括号改为

mr+log⁡∑jezrj−mr−zr,yr.

减最大值后,最大的指数只为 1,避免直接 exp(1000) 溢出。先 softmax 再 log 也可能因某类概率下溢为零而出问题,合并实现能更稳妥地算 log-probability。PyTorch 2.6 交叉熵接口源码

官方 E05 要替换为交叉熵并解释原因。本书测试用了包含 1000、1001 的 logits,确认直接指数出现无穷,而稳定交叉熵为有限值 0.23129559991076382,并与 logsumexp 表达式一致。这是稳定性的具体反例,不只是速度偏好。

6.4 为什么梯度是 “预测概率减目标” ​

对一条样本,L=log⁡∑jezj−zy。对第 k 个打分求导,第一项是 ezk/∑jezj=pk,第二项在 k=y 时导数为 1,否则为 0。因此

∂L∂zk=pk−1[k=y].

1[条件] 是指示函数,条件成立取 1,否则取 0。平均 B 条样本后,每项再除以 B。这个简洁结果解释了训练方向:真实类别的概率小于 1 时,其梯度为负,减去梯度就提高对应打分。

输入为字符 i 的样本只直接使用 W 的第 i 行。若同批多条样本有相同输入,它们的行梯度相加,正如 micrograd 中多条路径共享同一节点。language_lab.py 检查了这个性质,也用 torch.autograd.gradcheck 核对了交叉熵的梯度。

6.5 PyTorch 训练循环中的几个具体动作 ​

python
W = torch.randn((V, V), generator=g, requires_grad=True)
for step in range(400):
    loss = F.cross_entropy(W[xs], ys)
    W.grad = None
    loss.backward()
    with torch.no_grad():
        W -= learning_rate * W.grad

requires_grad=True 让前向运算记录求导关系。W.grad=None 表示下一次反向重新建立梯度;不清空则会与先前结果相加。参数更新放进 no_grad,使 “如何改参数” 本身不再进入下一张求导图。课程常用 .data 更新,为了教学短小直观;本书用 no_grad 使边界更明确。

六字符小实验以学习率 1.0 跑 400 次全批量更新,训练 NLL 从 2.139029 降到 0.928397。同一份数据的未经平滑计数最优值为 0.915590,神经网络训练还没有精确达到它。这个差距体现有限优化步数,并不表示 bigram 神经网络额外知道了更多历史。

6.6 平滑与权重惩罚相似,但不相等 ​

课程给损失增加 0.01*(W**2).mean()。若总共有 V2 个权重,正则项为

R=λV2∑i,jWij2,∂R∂Wij=2λV2Wij.

它把 logits 拉向零,而一行全零的 softmax 是均匀分布。这与加法平滑都有 “不让训练频次独占判断” 的效果。但是二者的目标函数、最优解和参数尺度不同,没有普遍的 “某个 λ 就等于加某个 α” 公式。本书不把两者称为严格等价。

比较维度计数 bigram神经网络 bigram
使用的历史一个字符一个字符
表示每行频数或概率每行 logits
拟合方式一次统计与归一化多次梯度更新
平滑/约束加伪计数等可加参数惩罚等
向复杂模型扩展上下文表快速增大可加入嵌入与共享隐藏层

练习与解答 ​

补充题:证明 W[xs] 与 one-hot 乘法不仅前向相同,反向也相同。对 Wij 求导,只有输入 ID 为 i 的样本有贡献,两种写法都把这些样本第 j 个输出的梯度求和。

官方 E06 要自己设计一道有趣的实验。本书选择温度采样:把 logits 除以正数 T 再 softmax。它不改变最大打分类别,但会改变概率的集中程度。examples/part1/exercises.py 用 [2,1,0] 实测 T=0.5,1,2 的熵约为 0.4411,0.8324,1.0202。这个小例子展示分布变平;它没有说明提高温度一定改善名字质量。

来源与边界 ​

本章覆盖 bigram notebook 的训练对、one-hot、矩阵乘法、softmax、逐条与向量化损失、反向更新、全数据训练、正则化和神经网络采样。官方 E04、E05 在此解答,E06 是按官方开放题要求选取的原创实验。全部测试与损失数值来自 qa/part1/language.log、exercises.log,并非课程全数据训练成绩。

保留各书来源与许可 · 示例运行条件以正文为准