Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

语言模型在学习什么

“我想喝一杯……”读到这里,你可能想到茶、咖啡或水。没有唯一答案,但“桌子”和“螺丝刀”通常不会排在最前面。

语言模型把这种续写问题写成了概率计算:给定已经出现的词元,为下一个词元分配概率。它可以很小,小到只是几张计数表;也可以很大,用神经网络处理很长的上下文。先把任务看清楚,再换更强的计算结构。

1. 一句话,怎样拆成许多次预测

设一句话编码后是 x1,x2,…,xTx_1,x_2,\ldots,x_T。概率的链式法则告诉我们:

P(x1,…,xT)=∏t=1TP(xt∣x1,…,xt−1) P(x_1,\ldots,x_T) =\prod_{t=1}^{T}P(x_t\mid x_1,\ldots,x_{t-1})

最前面没有正文时,可以约定一个起始记号 BOS。为了让模型学会什么时候停,末尾还可以加入 EOS。本章将 EOS 算作预测目标,BOS 只提供开始的上下文。

用一个四步小例子看得更清楚:

已知前文这一步的目标示意概率
BOS我0.5
BOS 我爱0.8
BOS 我 爱茶0.6
BOS 我 爱 茶EOS0.9

表中只列出了真实目标的概率。每一步仍需要为整个词表分配概率,并且总和为 1。这条完整序列的概率是:

0.5×0.8×0.6×0.9=0.216 0.5\times0.8\times0.6\times0.9=0.216

这些数字由我们手工指定,用来解释计算。后面的实验会从训练文本统计概率。

“预测下一个词元”也不是只学句尾填空。一条长度为 TT 的序列,可以提供多个位置的训练目标。只要输入和目标错开一位,就能形成:

输入:BOS  我  爱  茶
目标: 我  爱  茶  EOS

训练时使用真实前文,常称为 teacher forcing。目标已经在文本里,因此这是一种自监督任务。模型看见当前输入后预测下一项;它不能偷看这一项及更后面的答案。后面实现注意力时,我们会专门限制这条信息通路。

2. 先做一个只会数数的模型

假设每次只看前一个词元,那么:

P(xt∣x1,…,xt−1)≈P(xt∣xt−1) P(x_t\mid x_1,\ldots,x_{t-1}) \approx P(x_t\mid x_{t-1})

这叫二元语言模型,也写作 bigram 或 2-gram。“二元”把前一个词元和当前目标一起算进去,并不是看前面两个。

类似地,1-gram 不看前文,3-gram 看前两个。一般的 n-gram 看最近 n−1n-1 个词元,超过这个窗口的信息就用不上了。

本章训练语料中,“喝”后面出现了 3 次“红”、3 次“绿”,最直接的估计就是:

P(红∣喝)=36=0.5 P(\text{红}\mid\text{喝})=\frac{3}{6}=0.5

计数模型的问题很快出现:没见过的搭配会得到零概率。一句话只要有一步为零,整句概率就为零,对数损失则变成无穷大。

最简单的补救是加法平滑。设上下文为 cc,目标词元为 ww,输出词表有 VV 项:

P(w∣c)=C(c,w)+αC(c)+αV P(w\mid c)= \frac{C(c,w)+\alpha}{C(c)+\alpha V}

C(c,w)C(c,w) 是这组搭配的次数,C(c)C(c) 是该上下文的所有后续词元次数,α>0\alpha>0 是给每个词元补上的小计数。本例取 0.1。

分子给每个候选都加 α\alpha,分母也要把这 VV 份增量算进去,概率才仍然加起来等于 1。如果整个上下文都没见过,所有候选就得到相同概率 1/V1/V。

这可以避免零概率,却不能凭空教会模型合理搭配:没有证据时平均分配概率,只是一种明确的回退选择。更成熟的 n-gram 方法还有折扣、插值与回退等设计;这里先保留一个容易逐项检查的版本。

图 1:使用实际训练文本和 α=0.1\alpha=0.1。在“喝”后,红、绿各占约 0.419;在“茶”后,句号占 0.875。被平滑分配到其他候选的概率并没有消失。

3. 交叉熵,为什么能衡量预测

我们已经在分类中遇到交叉熵。语言模型只是把一次分类重复到许多位置:每次从词表的 VV 个候选中,预测实际出现的那一项。

若总共评估 NN 个目标,平均损失为:

L=−1N∑t=1Nln⁡P(xt∣对应的真实前文) L=-\frac{1}{N}\sum_{t=1}^{N} \ln P(x_t\mid\text{对应的真实前文})

多句话的损失可以把各句目标一起计数,但每句开始时要按约定重置上下文。本章使用自然对数,损失单位是 nat/词元。

模型给真实词元的概率越高,这一项损失越小。给错误答案很高的概率,就会挤压真实目标的概率,让损失增加。训练时我们希望降低训练损失;比较方案时仍要看独立验证文本。

语言模型还常报告困惑度,英文 perplexity,缩写 PPL:

PPL⁡=exp⁡(L) \operatorname{PPL}=\exp(L)

它和交叉熵描述同一组预测,只是换了刻度。如果每个位置都给真实目标 1/101/10 的概率,平均交叉熵是 ln⁡10\ln10,困惑度就是 10。这可以帮助理解“等效地有多少选择”,但 PPL 不一定是整数,也不是每一步都恰好有这么多候选。

前面四步示意中:

L=−ln⁡0.2164≈0.383,PPL⁡≈1.467 L=-\frac{\ln0.216}{4}\approx0.383,\qquad \operatorname{PPL}\approx1.467

若使用以 2 为底的对数,损失单位会变成 bit/词元,恢复困惑度时也要用 2L2^L。不要把两种底数混用。

比较困惑度之前,还需要对齐文本、词表、切分方式、上下文与计数规则。同一段话按字切和按子词切,目标个数与目标事件已经变了,不能只凭两个 PPL 数字判断模型高下。大量未见字符都被合成 UNK 时,也可能让评估失真。

4. 让模型自己继续写

预测概率只是第一步。生成时,我们从概率分布中选出一个词元,把它接到已有文本后面,再计算下一步。这种把自身输出接回输入的过程叫自回归生成。

例如,模型在“我爱喝”后选择了“绿”,下一步面对的就是“我爱喝绿”。它不会重新要求人提供正确前文。

这里有两种很容易区分的策略:

  • 每一步选概率最高的词元,叫贪心选择。
  • 按概率抽签,叫采样。概率较小的词元仍可能被选到。

采样可以产生不同文本,也可能选出不合适的词元;局部贪心也不保证整句最合理或整句概率最大。本章用固定随机种子的采样,方便看到同一次实验。温度、top-k 和 top-p 等策略,会在小型语言模型实践中继续展开。

生成遇到 EOS 就结束,并设置最大长度作为另一条停止条件。句号和 EOS 不是同一个东西:一个是正文字符,一个是我们约定的结束记号。模型完全可能在句号后继续生成,尤其当平滑给其他转移也分配了概率时。

还有一个训练与生成的差别:计算训练或验证损失时,前文来自真实文本;自由生成时,前文来自模型自己。某一步写错以后,后面就可能进入训练中少见的上下文。较低的验证损失值得关注,但它不能替代对真实生成结果的检查。

5. 神经语言模型,替换了哪一部分

n-gram 把每个上下文当作一张计数表的索引。“我想喝”和“你想喝”之间有什么可共享的规律,需要额外设计;窗口越长,不同组合越多,很多组合只见过一两次。

神经语言模型尝试把上下文先映射成向量,再用共享参数计算下一词元的分数。例如,取最近两个词元的嵌入,拼成向量 u\boldsymbol u:

u=[et−2;et−1]h=tanh⁡(Wu+b)z=Uh+ap=softmax⁡(z) \begin{aligned} \boldsymbol u&=[\boldsymbol e_{t-2};\boldsymbol e_{t-1}]\\ \boldsymbol h&=\tanh(W\boldsymbol u+\boldsymbol b)\\ \boldsymbol z&=U\boldsymbol h+\boldsymbol a\\ \boldsymbol p&=\operatorname{softmax}(\boldsymbol z) \end{aligned}

分号表示拼接,这里按列向量写公式。若每个嵌入有 dd 维,隐藏层有 mm 个单元,那么 u\boldsymbol u 为 2d2d 维,WW 为 m×2dm\times2d,UU 为 V×mV\times m。最后仍然得到词表上 VV 个概率。

和前面的分类网络相比,主要变化是输入来自多个词元的嵌入,输出类别对应词表。反向传播会同时调整嵌入表和网络参数,让它们一起服务于预测任务。

这给不同上下文共享统计规律提供了机会,但不会保证模型总能正确泛化。这里只写出计算结构,没有把随机网络冒充已经训练好的语言模型。接下来的章节会用注意力扩大上下文处理能力,并真正训练一个小型 Transformer。

6. 运行三种计数模型

实验使用 9 句自编短句训练,3 句验证,3 句测试,词汇围绕“我、你、他”和“红茶、绿茶”。三个集合没有相同的完整句子,但共享词语和模板,这是一个有意简化的组合实验,不能代表开放文本上的语言能力。

我们固定字符级词表和 α=0.1\alpha=0.1,比较 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. 自测与小实验

  1. 3-gram 通常看前几个词元?
  2. 加法平滑为什么在分母加 αV\alpha V?
  3. 一个完全没见过的上下文,在本例中得到什么分布?
  4. 每个真实目标的概率都是 0.25 时,平均自然对数交叉熵和困惑度分别是多少?
  5. 两个模型分别按字符和子词切分,能否直接用困惑度判断谁更好?
  6. 验证集有三句,每句六个字符,加入 EOS 后为什么有 21 个目标?
  7. 将代码改为每步取最大概率,观察生成文本与固定种子采样有什么差异。
  8. 在验证集上比较几个 α\alpha,选定后再评估测试集。为什么不应该反复根据测试结果挑 α\alpha?
参考答案与提示
  1. 前两个;n 包含当前预测目标。
  2. 每个候选都加了 α\alpha,共有 VV 个候选,需要保持概率归一化。
  3. 均匀分布,每项为 1/141/14;这是简单回退,并不表示所有后续都同样合理。
  4. ln⁡4≈1.386\ln4\approx1.386 nat/词元,困惑度为 4。
  5. 不能直接比较。事件单位、目标数量和词表等条件需要对齐。
  6. 每句 6+1=76+1=7 个目标;BOS 只作输入,不计入目标。
  7. 可以用最大概率的索引替代 rng.choices。先规定并列时怎样选;贪心结果确定,却不保证完整序列最优,也可能重复。
  8. 测试集用于评估已选定的方案。反复据此调参,会把测试信息带入方案选择,使分数过于乐观。

下一章的注意力机制会回答:当前这个位置,怎样从多个历史位置取出有用的信息?

继续阅读:Bengio 等人的神经概率语言模型论文展示了分布式表示与语言预测的结合;Jurafsky 与 Martin 的 n-gram 章节可用来深入学习平滑和困惑度。本章的训练句子、数值例子与实验代码均为教学设计。