语言模型在学习什么
“我想喝一杯……”读到这里,你可能想到茶、咖啡或水。没有唯一答案,但“桌子”和“螺丝刀”通常不会排在最前面。
语言模型把这种续写问题写成了概率计算:给定已经出现的词元,为下一个词元分配概率。它可以很小,小到只是几张计数表;也可以很大,用神经网络处理很长的上下文。先把任务看清楚,再换更强的计算结构。
1. 一句话,怎样拆成许多次预测
设一句话编码后是 。概率的链式法则告诉我们:
最前面没有正文时,可以约定一个起始记号 BOS。为了让模型学会什么时候停,末尾还可以加入 EOS。本章将 EOS 算作预测目标,BOS 只提供开始的上下文。
用一个四步小例子看得更清楚:
| 已知前文 | 这一步的目标 | 示意概率 |
|---|---|---|
| BOS | 我 | 0.5 |
| BOS 我 | 爱 | 0.8 |
| BOS 我 爱 | 茶 | 0.6 |
| BOS 我 爱 茶 | EOS | 0.9 |
表中只列出了真实目标的概率。每一步仍需要为整个词表分配概率,并且总和为 1。这条完整序列的概率是:
这些数字由我们手工指定,用来解释计算。后面的实验会从训练文本统计概率。
“预测下一个词元”也不是只学句尾填空。一条长度为 的序列,可以提供多个位置的训练目标。只要输入和目标错开一位,就能形成:
输入:BOS 我 爱 茶
目标: 我 爱 茶 EOS
训练时使用真实前文,常称为 teacher forcing。目标已经在文本里,因此这是一种自监督任务。模型看见当前输入后预测下一项;它不能偷看这一项及更后面的答案。后面实现注意力时,我们会专门限制这条信息通路。
2. 先做一个只会数数的模型
假设每次只看前一个词元,那么:
这叫二元语言模型,也写作 bigram 或 2-gram。“二元”把前一个词元和当前目标一起算进去,并不是看前面两个。
类似地,1-gram 不看前文,3-gram 看前两个。一般的 n-gram 看最近 个词元,超过这个窗口的信息就用不上了。
本章训练语料中,“喝”后面出现了 3 次“红”、3 次“绿”,最直接的估计就是:
计数模型的问题很快出现:没见过的搭配会得到零概率。一句话只要有一步为零,整句概率就为零,对数损失则变成无穷大。
最简单的补救是加法平滑。设上下文为 ,目标词元为 ,输出词表有 项:
是这组搭配的次数, 是该上下文的所有后续词元次数, 是给每个词元补上的小计数。本例取 0.1。
分子给每个候选都加 ,分母也要把这 份增量算进去,概率才仍然加起来等于 1。如果整个上下文都没见过,所有候选就得到相同概率 。
这可以避免零概率,却不能凭空教会模型合理搭配:没有证据时平均分配概率,只是一种明确的回退选择。更成熟的 n-gram 方法还有折扣、插值与回退等设计;这里先保留一个容易逐项检查的版本。
图 1:使用实际训练文本和 。在“喝”后,红、绿各占约 0.419;在“茶”后,句号占 0.875。被平滑分配到其他候选的概率并没有消失。
3. 交叉熵,为什么能衡量预测
我们已经在分类中遇到交叉熵。语言模型只是把一次分类重复到许多位置:每次从词表的 个候选中,预测实际出现的那一项。
若总共评估 个目标,平均损失为:
多句话的损失可以把各句目标一起计数,但每句开始时要按约定重置上下文。本章使用自然对数,损失单位是 nat/词元。
模型给真实词元的概率越高,这一项损失越小。给错误答案很高的概率,就会挤压真实目标的概率,让损失增加。训练时我们希望降低训练损失;比较方案时仍要看独立验证文本。
语言模型还常报告困惑度,英文 perplexity,缩写 PPL:
它和交叉熵描述同一组预测,只是换了刻度。如果每个位置都给真实目标 的概率,平均交叉熵是 ,困惑度就是 10。这可以帮助理解“等效地有多少选择”,但 PPL 不一定是整数,也不是每一步都恰好有这么多候选。
前面四步示意中:
若使用以 2 为底的对数,损失单位会变成 bit/词元,恢复困惑度时也要用 。不要把两种底数混用。
比较困惑度之前,还需要对齐文本、词表、切分方式、上下文与计数规则。同一段话按字切和按子词切,目标个数与目标事件已经变了,不能只凭两个 PPL 数字判断模型高下。大量未见字符都被合成 UNK 时,也可能让评估失真。
4. 让模型自己继续写
预测概率只是第一步。生成时,我们从概率分布中选出一个词元,把它接到已有文本后面,再计算下一步。这种把自身输出接回输入的过程叫自回归生成。
例如,模型在“我爱喝”后选择了“绿”,下一步面对的就是“我爱喝绿”。它不会重新要求人提供正确前文。
这里有两种很容易区分的策略:
- 每一步选概率最高的词元,叫贪心选择。
- 按概率抽签,叫采样。概率较小的词元仍可能被选到。
采样可以产生不同文本,也可能选出不合适的词元;局部贪心也不保证整句最合理或整句概率最大。本章用固定随机种子的采样,方便看到同一次实验。温度、top-k 和 top-p 等策略,会在小型语言模型实践中继续展开。
生成遇到 EOS 就结束,并设置最大长度作为另一条停止条件。句号和 EOS 不是同一个东西:一个是正文字符,一个是我们约定的结束记号。模型完全可能在句号后继续生成,尤其当平滑给其他转移也分配了概率时。
还有一个训练与生成的差别:计算训练或验证损失时,前文来自真实文本;自由生成时,前文来自模型自己。某一步写错以后,后面就可能进入训练中少见的上下文。较低的验证损失值得关注,但它不能替代对真实生成结果的检查。
5. 神经语言模型,替换了哪一部分
n-gram 把每个上下文当作一张计数表的索引。“我想喝”和“你想喝”之间有什么可共享的规律,需要额外设计;窗口越长,不同组合越多,很多组合只见过一两次。
神经语言模型尝试把上下文先映射成向量,再用共享参数计算下一词元的分数。例如,取最近两个词元的嵌入,拼成向量 :
分号表示拼接,这里按列向量写公式。若每个嵌入有 维,隐藏层有 个单元,那么 为 维, 为 , 为 。最后仍然得到词表上 个概率。
和前面的分类网络相比,主要变化是输入来自多个词元的嵌入,输出类别对应词表。反向传播会同时调整嵌入表和网络参数,让它们一起服务于预测任务。
这给不同上下文共享统计规律提供了机会,但不会保证模型总能正确泛化。这里只写出计算结构,没有把随机网络冒充已经训练好的语言模型。接下来的章节会用注意力扩大上下文处理能力,并真正训练一个小型 Transformer。
6. 运行三种计数模型
实验使用 9 句自编短句训练,3 句验证,3 句测试,词汇围绕“我、你、他”和“红茶、绿茶”。三个集合没有相同的完整句子,但共享词语和模板,这是一个有意简化的组合实验,不能代表开放文本上的语言能力。
我们固定字符级词表和 ,比较 1-gram、2-gram、3-gram。词表只从训练文本建立,验证与测试没有参与计数;按验证交叉熵选好方案后,再评估测试集。
脚本只使用标准库,可下载完整脚本:
python3 src/examples/ngram_language_model.py
"""第 18 章:加法平滑的 n-gram 语言模型;只用标准库。"""
from collections import Counter, defaultdict
from math import exp, log
from random import Random
TRAIN = [
"我爱喝红茶。", "你爱喝绿茶。", "他爱喝红茶。",
"我想喝绿茶。", "你想喝红茶。", "他想喝绿茶。",
"我买了红茶。", "你买了绿茶。", "他买了红茶。",
]
VALIDATION = ["我爱喝绿茶。", "你想喝绿茶。", "他买了绿茶。"]
TEST = ["他爱喝绿茶。", "我想喝红茶。", "你买了红茶。"]
BOS, EOS, UNK = "<BOS>", "<EOS>", "<UNK>"
class NGram:
def __init__(self, texts, order, alpha=0.1):
if order < 1 or alpha <= 0:
raise ValueError("order 至少为 1,alpha 必须大于 0")
self.order, self.alpha = order, alpha
self.vocab = sorted({char for text in texts for char in text})+[EOS, UNK]
self.counts = defaultdict(Counter)
for text in texts:
for context, target in self.events(text):
self.counts[context][target] += 1
def context(self, history):
return tuple(history[-(self.order-1):]) if self.order > 1 else ()
def events(self, text):
history = [BOS]*(self.order-1)
for target in list(text)+[EOS]:
target = target if target in self.vocab else UNK
yield self.context(history), target
history.append(target)
def distribution(self, context):
counts = self.counts.get(context, {})
total = sum(counts.values())+self.alpha*len(self.vocab)
return [(counts.get(token, 0)+self.alpha)/total for token in self.vocab]
def evaluate(self, texts):
negative_log_probability, total = 0.0, 0
for text in texts:
for context, target in self.events(text):
probability = self.distribution(context)[self.vocab.index(target)]
negative_log_probability -= log(probability)
total += 1
loss = negative_log_probability/total
return loss, exp(loss), total
def generate(self, seed=7, limit=24):
rng, history, result = Random(seed), [BOS]*(self.order-1), []
for _ in range(limit):
probabilities = self.distribution(self.context(history))
token = rng.choices(self.vocab, weights=probabilities, k=1)[0]
if token == EOS:
return "".join(result), "遇到 EOS"
history.append(token)
result.append(token)
return "".join(result), "到达长度上限"
def run():
models = [NGram(TRAIN, order) for order in [1, 2, 3]]
selected = min(models, key=lambda model: model.evaluate(VALIDATION)[0])
return models, selected
def main():
models, selected = run()
print(f"训练/验证/测试句数:{len(TRAIN)} / {len(VALIDATION)} / {len(TEST)}")
print(f"统一预测词表大小:{len(selected.vocab)};损失包含 EOS,不包含 BOS")
for model in models:
train_loss, _, _ = model.evaluate(TRAIN)
val_loss, ppl, count = model.evaluate(VALIDATION)
print(f"{model.order}-gram:训练交叉熵={train_loss:.3f},"
f"验证交叉熵={val_loss:.3f},困惑度={ppl:.3f},目标数={count}")
loss, ppl, _ = selected.evaluate(TEST)
print(f"固定方案:{selected.order}-gram;测试交叉熵={loss:.3f},困惑度={ppl:.3f}")
for model in models:
text, stop = model.generate()
print(f"{model.order}-gram 生成:{text!r}({stop})")
probability = 0.5*0.8*0.6*0.9
print(f"四步示意:联合概率={probability:.3f},"
f"平均交叉熵={-log(probability)/4:.3f},"
f"困惑度={probability**(-1/4):.3f}")
if __name__ == "__main__":
main()
输出为:
训练/验证/测试句数:9 / 3 / 3
统一预测词表大小:14;损失包含 EOS,不包含 BOS
1-gram:训练交叉熵=2.450,验证交叉熵=2.468,困惑度=11.796,目标数=21
2-gram:训练交叉熵=0.623,验证交叉熵=0.647,困惑度=1.910,目标数=21
3-gram:训练交叉熵=0.734,验证交叉熵=0.779,困惑度=2.178,目标数=21
固定方案:2-gram;测试交叉熵=0.612,困惑度=1.844
1-gram 生成:'你买红。爱喝。我。想。。喝茶。了红'(遇到 EOS)
2-gram 生成:'他买了他想喝你想买了他买了绿想喝绿茶。'(遇到 EOS)
3-gram 生成:'他买喝买我喝。我。想。买喝茶买他爱'(遇到 EOS)
四步示意:联合概率=0.216,平均交叉熵=0.383,困惑度=1.467
图 2:三种模型使用相同的输出词表和计数规则。更长的上下文在这组数据与平滑设置下没有取得更低损失。
为什么三元模型连训练损失也更高?它把上下文分得更细,每张计数表可用的样本更少;固定的平滑计数相对就更重。这里比较的是平滑后的模型,不能拿“更复杂的模型总能更贴合训练数据”来替代实际分析。
再看看采样文本。二元模型能接出“喝绿茶”这样的局部片段,但整句仍会重复、跳转。较低的困惑度没有让它获得完整语法或事实判断能力。九句训练数据、有限上下文和简单平滑,都限制了这个模型。
这个实验最值得记住的不是哪个 n 赢了,而是我们终于有了完整链条:文本提供目标,模型给概率,交叉熵衡量预测,自回归过程产生新文本。后面换成 Transformer,这条主线仍然在。
7. 自测与小实验
- 3-gram 通常看前几个词元?
- 加法平滑为什么在分母加 ?
- 一个完全没见过的上下文,在本例中得到什么分布?
- 每个真实目标的概率都是 0.25 时,平均自然对数交叉熵和困惑度分别是多少?
- 两个模型分别按字符和子词切分,能否直接用困惑度判断谁更好?
- 验证集有三句,每句六个字符,加入 EOS 后为什么有 21 个目标?
- 将代码改为每步取最大概率,观察生成文本与固定种子采样有什么差异。
- 在验证集上比较几个 ,选定后再评估测试集。为什么不应该反复根据测试结果挑 ?
参考答案与提示
- 前两个;n 包含当前预测目标。
- 每个候选都加了 ,共有 个候选,需要保持概率归一化。
- 均匀分布,每项为 ;这是简单回退,并不表示所有后续都同样合理。
- nat/词元,困惑度为 4。
- 不能直接比较。事件单位、目标数量和词表等条件需要对齐。
- 每句 个目标;BOS 只作输入,不计入目标。
- 可以用最大概率的索引替代
rng.choices。先规定并列时怎样选;贪心结果确定,却不保证完整序列最优,也可能重复。 - 测试集用于评估已选定的方案。反复据此调参,会把测试信息带入方案选择,使分数过于乐观。
下一章的注意力机制会回答:当前这个位置,怎样从多个历史位置取出有用的信息?
继续阅读:Bengio 等人的神经概率语言模型论文展示了分布式表示与语言预测的结合;Jurafsky 与 Martin 的 n-gram 章节可用来深入学习平滑和困惑度。本章的训练句子、数值例子与实验代码均为教学设计。