Skip to content

第 5 章 从计数得到概率、采样和损失 ​

一张计数表可以用来预测,也可以用来生成。预测时,给定当前字符,查看下一字符的整行分布;生成时,从这行分布抽一个字符,再把抽到的结果作为新的当前字符。两种用法共享同一张概率表,区别只是怎样使用输出。

5.1 每一行单独归一化 ​

令 Nij 为转移次数,Ni=∑jNij 为第 i 行总数。只要 Ni>0,定义

Pij=P(xt+1=j∣xt=i)=NijNi.

t 是序列位置,i,j∈{0,…,V−1}。竖线表示 “在已知当前字符的条件下”。每一行的概率和为 1,并不是整个矩阵的和为 1。对 aba、ada,当前字符是 a 时,后面是结束符的概率为 2/4=0.5,是 b 或 d 的概率各为 1/4。

先取一行做除法最容易看懂:

python
p = N[0].to(torch.float64)
p = p / p.sum()

再扩展到整个矩阵:

python
P = N.to(torch.float64)
P = P / P.sum(dim=1, keepdim=True)

dim=1 表示沿第二维,也就是每行内部求和。keepdim=True 保留长度为 1 的第二维,使分母形状为 (V,1)。每一行的同一个分母就能扩展到该行所有列。

5.2 广播不会替你判断数学意图 ​

如果去掉 keepdim=True,行和的形状变成 (V,)。广播从最右边对齐,它相当于 (1,V),于是第 j 列会除以第 j 个行和。这通常能运行,因为矩阵是方阵,但含义错了。PyTorch 2.6 广播规则

操作数形状实际作用
计数矩阵(V,V)每行一个当前字符
sum(1, keepdim=True)(V,1)每行除以自己的总数
sum(1)(V,)右对齐到列,产生错误归一化

因此,形状检查还要配合性质检查:

python
assert torch.allclose(P.sum(dim=1), torch.ones(V, dtype=P.dtype))
assert torch.isfinite(P).all()
assert (P >= 0).all()

本书 language_lab.py 故意计算了一份缺少 keepdim 的错误结果,并断言它的行和不全为 1。方阵上静默通过的广播错误,在神经网络里远比直接抛异常更难发现。

5.3 从概率抽样,为什么要带随机数生成器 ​

torch.multinomial(p,1) 按非负权重抽一个类别 ID。对单个样本来说,出现低概率类别并不说明函数出错;要看重复抽样的频率是否接近概率。权重不必预先归一化,但必须有限、非负且总和为正。本书为解释方便传入已归一化概率。PyTorch 2.6 multinomial 文档源码

python
g = torch.Generator().manual_seed(42)
current = 0
result = []
for _ in range(100):
    current = torch.multinomial(P[current], 1, generator=g).item()
    if current == 0:
        break
    result.append(itos[current])
else:
    raise RuntimeError('Generation reached the safety length limit')

从起始符 0 开始,生成 0 时停止。这里给生成加了长度保护;它防止异常模型让程序运行过久,不等于模型本身学习了长度限制。若触发上限,应明确标记截断,不能把它当成自然结束。

创建一次 Generator,多次抽样会依次消耗它的状态。若在每个名字开头重新创建同种子的生成器,可能反复得到同一个名字。固定种子是为了复现实验,不是提高文本质量。跨 PyTorch 版本、设备或算法,不能仅凭种子承诺逐字符相同。

如果把每行都换成均匀分布,程序仍然会正常生成字符,但失去数据学到的转移结构。这个基线能帮助区分 “采样循环正确” 和 “模型确实使用了数据”。

5.4 给整份数据打分 ​

生成几个看起来顺眼的名字不足以评价模型。更稳定的办法是检查它给真实后继字符分配了多少概率。

以 aba 为例,它连同结束符的概率,在 bigram 假设下是

P(aba结束)=P(a∣.)P(b∣a)P(a∣b)P(.∣a).

真实序列的链式分解本来可以条件于全部过去。bigram 用最后一个字符代替全部历史,这是模型假设,不是概率恒等式要求历史只能有一个字符。

许多小概率相乘容易下溢,且不便优化。对数把乘法变加法,并保留大小顺序。把数据中的转移编号为 r=1,…,M,其当前字符为 xr,真实后继为 yr,平均负对数似然定义为

L=−1M∑r=1Mlog⁡Pxr,yr.

“似然” 指把数据固定,比较不同模型给这些数据分配的概率;“负” 号让目标变成越小越好;除以 M 使不同样本量的结果较易比较。这里使用自然对数,单位可写作 nats/字符预测。结束符也算一次预测。

若模型给正确类别概率 1,该项损失是 0;概率 1/2 时约为 0.693;概率 1/100 时约为 4.605。它会重罚自信地排除真实答案的预测。若每个类别都均匀分到 1/V,平均损失恰好为 log⁡V。对 27 个类别约为 3.296,对六个类别约为 1.792;换了词表,基线就变了。

5.5 没见过不等于不可能:加法平滑 ​

原始计数为零的转移对应概率零,一旦评估集出现它,−log⁡0=+∞。而整行没有出现时,连归一化都变成 0/0。一种简单补救是给每格加正数 α:

Pij=Nij+αNi+αV.

α=1 叫加一平滑。它把每行分布向均匀分布拉近,尤其影响数据稀少的行。没有见过的整行会变成均匀分布。数据丰富的行受影响较小,因为 αV 相对真实计数更小。

python
P = N.to(torch.float64) + alpha
P /= P.sum(dim=1, keepdim=True)

平滑不是免费提升。它给未见事件留出了概率,必然从已见事件那里拿走一部分概率。本书六字符、40 条自编样本上,未平滑的训练集最大似然损失为 0.915590,加一后是 0.998357。训练损失变高符合预期;是否改善新数据表现,要看验证集,而不能从训练损失独自得出结论。

在一个行总数 Ni>0 的多项分布中,为什么相对频率 Nij/Ni 是最大似然估计?给约束 ∑jpj=1 引入乘子 λ,最大化 ∑jNijlog⁡pj+λ(∑jpj−1)。对 pj 求导得 Nij/pj+λ=0,所以 pj=−Nij/λ。求和可得 λ=−Ni,于是 pj=Nij/Ni。零计数的最优概率可以位于边界零;这正是平滑要调整的地方。

练习与答案 ​

补充题:在有 6 个类别、行计数总数 4 的情况下,加一平滑给每个未见事件多少概率?答案是 1/(4+6)=0.1。出现两次的事件概率变为 3/10,不再是 2/4。

官方 bigram E02、E03 要先切分训练、验证、测试集,再选择平滑强度。第 9 章会给出完整可运行网格实验。先写出一条原则:验证集用来选 α,测试集在选择确定之后才用于报告;不能把计数建在全数据上,再声称在留出集评估。

来源与运行边界 ​

本章覆盖 bigram notebook 的单行分布、随机抽样、广播探索、全表归一化、生成、似然与平滑。notebook 保留了交互式运行痕迹,部分形状查询位于完整赋值前;本书代码按可顺序运行的依赖整理。真实检查在 language_lab.py,完整日志在 qa/part1/language.log。

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