9.3. 语言模型
在 Colab 中打开 Notebook
在 Colab 中打开 Notebook
在 Colab 中打开 Notebook
在 Colab 中打开 Notebook
在 SageMaker Studio Lab 中打开 Notebook

第 9.2 节 中,我们学习了如何将文本序列映射为词元,这些词元可以看作是离散观测值的序列,例如单词或字符。假设长度为 \(T\) 的文本序列中的词元依次是 \(x_1, x_2, \ldots, x_T\)。*语言模型* 的目标是估计整个序列的联合概率

(9.3.1)\[P(x_1, x_2, \ldots, x_T),\]

其中可以应用 第 9.1 节 中的统计工具。

语言模型非常有用。例如,一个理想的语言模型应该能够自行生成自然的文本,只需一次生成一个词元 \(x_t \sim P(x_t \mid x_{t-1}, \ldots, x_1)\)。与使用打字机的猴子完全不同,从这样的模型中产生的所有文本都将作为自然语言(例如英语文本)通过。此外,它足以通过根据之前的对话片段来调整文本,从而生成有意义的对话。显然,我们离设计这样的系统还很远,因为它需要*理解*文本,而不仅仅是生成语法上合理的内容。

尽管如此,语言模型即使在其有限的形式下也具有很大的用处。例如,短语“to recognize speech”(识别语音)和“to wreck a nice beach”(毁掉一个美丽的海滩)听起来非常相似。这可能在语音识别中引起歧义,而语言模型可以轻易地解决这个问题,因为它会拒绝第二个翻译,认为其不合常理。同样,在文档摘要算法中,知道“狗咬人”比“人咬狗”频繁得多,或者“I want to eat grandma”(我想吃奶奶)是一个相当令人不安的陈述,而“I want to eat, grandma”(奶奶,我想吃饭了)则要温和得多,这是很有价值的。

import torch
from d2l import torch as d2l
from mxnet import np, npx
from d2l import mxnet as d2l

npx.set_np()
from jax import numpy as jnp
from d2l import jax as d2l
No GPU/TPU found, falling back to CPU. (Set TF_CPP_MIN_LOG_LEVEL=0 and rerun for more info.)
import tensorflow as tf
from d2l import tensorflow as d2l

9.3.1. 学习语言模型

显而易见的问题是,我们应该如何为一个文档,甚至是一个词元序列建模。假设我们在单词级别对文本数据进行词元化。让我们从应用基本的概率规则开始

(9.3.2)\[P(x_1, x_2, \ldots, x_T) = \prod_{t=1}^T P(x_t \mid x_1, \ldots, x_{t-1}).\]

例如,一个包含四个单词的文本序列的概率可以表示为

(9.3.3)\[\begin{split}\begin{aligned}&P(\textrm{deep}, \textrm{learning}, \textrm{is}, \textrm{fun}) \\ =&P(\textrm{deep}) P(\textrm{learning} \mid \textrm{deep}) P(\textrm{is} \mid \textrm{deep}, \textrm{learning}) P(\textrm{fun} \mid \textrm{deep}, \textrm{learning}, \textrm{is}).\end{aligned}\end{split}\]

9.3.1.1. 马尔可夫模型和 \(n\)-gram

第 9.1 节 的序列模型分析中,让我们将马尔可夫模型应用于语言建模。如果一个序列的分布满足一阶马尔可夫性质,即 \(P(x_{t+1} \mid x_t, \ldots, x_1) = P(x_{t+1} \mid x_t)\)。阶数越高,对应于更长的依赖关系。这导致了我们可以应用于序列建模的一些近似方法

(9.3.4)\[\begin{split}\begin{aligned} P(x_1, x_2, x_3, x_4) &= P(x_1) P(x_2) P(x_3) P(x_4),\\ P(x_1, x_2, x_3, x_4) &= P(x_1) P(x_2 \mid x_1) P(x_3 \mid x_2) P(x_4 \mid x_3),\\ P(x_1, x_2, x_3, x_4) &= P(x_1) P(x_2 \mid x_1) P(x_3 \mid x_1, x_2) P(x_4 \mid x_2, x_3). \end{aligned}\end{split}\]

涉及一个、两个和三个变量的概率公式通常分别称为*一元语法* (unigram)、*二元语法* (bigram) 和*三元语法* (trigram) 模型。为了计算语言模型,我们需要计算单词的概率以及给定前几个单词的条件下某个单词的条件概率。请注意,这些概率是语言模型的参数。

9.3.1.2. 词频

在这里,我们假设训练数据集是一个大型文本语料库,例如所有维基百科条目、古腾堡计划 以及网上发布的所有文本。单词的概率可以从训练数据集中给定单词的相对词频计算得出。例如,估计 \(\hat{P}(\textrm{deep})\) 可以计算为任何以单词“deep”开头的句子的概率。一个稍微不那么准确的方法是计算单词“deep”的所有出现次数,然后除以语料库中的总词数。这种方法效果相当好,尤其是对于常用词。接下来,我们可以尝试估计

(9.3.5)\[\hat{P}(\textrm{learning} \mid \textrm{deep}) = \frac{n(\textrm{deep, learning})}{n(\textrm{deep})},\]

其中 \(n(x)\)\(n(x, x')\) 分别是单个单词和连续词对的出现次数。不幸的是,估计一个词对的概率要困难一些,因为“deep learning”的出现次数要少得多。特别是,对于一些不常见的词语组合,可能很难找到足够的出现次数来获得准确的估计。正如 第 9.2.5 节 的经验结果所表明的,对于三词组合及以上的组合,情况变得更糟。将会有很多看似合理的三词组合,我们很可能在数据集中看不到。除非我们提供某种解决方案来为这些词语组合分配一个非零计数,否则我们将无法在语言模型中使用它们。如果数据集很小或者单词非常罕见,我们甚至可能一个都找不到。

9.3.1.3. 拉普拉斯平滑

一个常见的策略是执行某种形式的*拉普拉斯平滑*。解决方案是给所有计数加上一个小的常数。用 \(n\) 表示训练集中的总词数,\(m\) 表示唯一词的数量。这个解决方案有助于处理单个词,例如,通过

(9.3.6)\[\begin{split}\begin{aligned} \hat{P}(x) & = \frac{n(x) + \epsilon_1/m}{n + \epsilon_1}, \\ \hat{P}(x' \mid x) & = \frac{n(x, x') + \epsilon_2 \hat{P}(x')}{n(x) + \epsilon_2}, \\ \hat{P}(x'' \mid x,x') & = \frac{n(x, x',x'') + \epsilon_3 \hat{P}(x'')}{n(x, x') + \epsilon_3}. \end{aligned}\end{split}\]

这里 \(\epsilon_1,\epsilon_2\)\(\epsilon_3\) 是超参数。以 \(\epsilon_1\) 为例:当 \(\epsilon_1 = 0\) 时,不应用平滑;当 \(\epsilon_1\) 趋近于正无穷大时,\(\hat{P}(x)\) 趋近于均匀概率 \(1/m\)。以上是其他技术可以实现的一个相当原始的变体 (Wood et al., 2011)

不幸的是,由于以下原因,这样的模型很快变得难以处理。首先,如 第 9.2.5 节 中所讨论的,许多 \(n\)-grams 出现得非常少,使得拉普拉斯平滑不太适合语言建模。其次,我们需要存储所有计数。第三,这完全忽略了词语的含义。例如,“cat”和“feline”应该出现在相关的语境中。要将此类模型调整到其他语境中相当困难,而基于深度学习的语言模型则非常适合考虑这一点。最后,长词序列几乎肯定是新颖的,因此一个仅仅计算先前见过的词序列频率的模型注定在那里表现不佳。因此,在本章的其余部分,我们专注于使用神经网络进行语言建模。

9.3.2. 困惑度

接下来,让我们讨论如何衡量语言模型的质量,我们将在后续章节中用它来评估我们的模型。一种方法是检查文本有多么“令人惊讶”。一个好的语言模型能够高精度地预测接下来出现的词元。考虑以下对短语“It is raining”的不同语言模型提出的续写:

  1. “It is raining outside”(外面在下雨)

  2. “It is raining banana tree”(在下雨香蕉树)

  3. “It is raining piouw;kcj pwepoiut”

就质量而言,例1显然是最好的。这些词语合情合理且逻辑连贯。虽然它可能不完全准确地反映了语义上接下来会出现哪个词(“in San Francisco”和“in winter”本可以是完全合理的扩展),但该模型能够捕捉到哪种类型的词会跟在后面。例2要差得多,因为它产生了一个无意义的扩展。尽管如此,至少该模型已经学会了如何拼写单词以及单词之间某种程度的相关性。最后,例3表明模型训练不佳,未能正确拟合数据。

我们可以通过计算序列的似然度来衡量模型的质量。不幸的是,这个数字很难理解,也难以比较。毕竟,较短的序列比长的序列出现的可能性要大得多,因此在托尔斯泰的巨著《战争与和平》上评估模型,不可避免地会产生比在圣埃克苏佩里的中篇小说《小王子》上小得多的似然度。所缺少的是一个等效的平均值。

信息论在这里派上了用场。我们在介绍 softmax 回归时(第 4.1.3 节)定义了熵、惊奇度和交叉熵。如果我们想压缩文本,我们可以询问在给定当前词元集的情况下预测下一个词元。一个更好的语言模型应该能让我们更准确地预测下一个词元。因此,它应该能让我们在压缩序列时花费更少的比特。所以我们可以用在一个序列的所有 \(n\) 个词元上平均的交叉熵损失来衡量它:

(9.3.7)\[\frac{1}{n} \sum_{t=1}^n -\log P(x_t \mid x_{t-1}, \ldots, x_1),\]

其中 \(P\) 由语言模型给出,\(x_t\) 是在时间步 \(t\) 从序列中观察到的实际词元。这使得不同长度文档的性能具有可比性。由于历史原因,自然语言处理领域的科学家更喜欢使用一个叫做*困惑度*(perplexity)的量。简而言之,它是 (9.3.7) 的指数:

(9.3.8)\[\exp\left(-\frac{1}{n} \sum_{t=1}^n \log P(x_t \mid x_{t-1}, \ldots, x_1)\right).\]

困惑度可以最好地理解为我们在决定下一个选择哪个词元时,真实选择数量的几何平均数的倒数。我们来看几个例子:

  • 在最好的情况下,模型总是完美地估计目标词元的概率为1。在这种情况下,模型的困惑度为1。

  • 在最坏的情况下,模型总是预测目标词元的概率为0。在这种情况下,困惑度为正无穷大。

  • 在基准情况下,模型预测词汇表中所有可用词元的均匀分布。在这种情况下,困惑度等于词汇表中唯一词元的数量。事实上,如果我们要不经任何压缩地存储序列,这将是我们编码它的最好方式。因此,这提供了一个任何有用的模型都必须超越的非平凡上界。

9.3.3. 划分序列

我们将使用神经网络设计语言模型,并使用困惑度来评估模型在给定文本序列中当前词元集的情况下预测下一个词元的能力。在介绍模型之前,我们假设它一次处理一个预定义长度的序列小批量。现在的问题是如何随机读取输入序列和目标序列的小批量。

假设数据集的形式是 corpus\(T\) 个词元索引的序列。我们将把它划分为子序列,其中每个子序列有 \(n\) 个词元(时间步)。为了在每个 epoch 迭代(几乎)整个数据集的所有词元,并获得所有可能的长度为 \(n\) 的子序列,我们可以引入随机性。具体来说,在每个 epoch 开始时,丢弃前 \(d\) 个词元,其中 \(d\in [0,n)\) 是随机均匀抽样的。序列的其余部分被划分为 \(m=\lfloor (T-d)/n \rfloor\) 个子序列。用 \(\mathbf x_t = [x_t, \ldots, x_{t+n-1}]\) 表示从时间步 \(t\) 的词元 \(x_t\) 开始的长度为 \(n\) 的子序列。得到的 \(m\) 个划分的子序列是 \(\mathbf x_d, \mathbf x_{d+n}, \ldots, \mathbf x_{d+n(m-1)}.\) 每个子序列将用作语言模型的输入序列。

对于语言建模,目标是根据我们目前看到的词元来预测下一个词元;因此目标(标签)是原始序列,向左移动一个词元。对于任何输入序列 \(\mathbf x_t\),其目标序列是长度为 \(n\)\(\mathbf x_{t+1}\)

../_images/lang-model-data.svg

图 9.3.1 从划分的长度为5的子序列中获取五对输入序列和目标序列。

图 9.3.1 显示了一个在 \(n=5\)\(d=2\) 的情况下获取五对输入序列和目标序列的示例。

@d2l.add_to_class(d2l.TimeMachine)  #@save
def __init__(self, batch_size, num_steps, num_train=10000, num_val=5000):
    super(d2l.TimeMachine, self).__init__()
    self.save_hyperparameters()
    corpus, self.vocab = self.build(self._download())
    array = torch.tensor([corpus[i:i+num_steps+1]
                        for i in range(len(corpus)-num_steps)])
    self.X, self.Y = array[:,:-1], array[:,1:]
@d2l.add_to_class(d2l.TimeMachine)  #@save
def __init__(self, batch_size, num_steps, num_train=10000, num_val=5000):
    super(d2l.TimeMachine, self).__init__()
    self.save_hyperparameters()
    corpus, self.vocab = self.build(self._download())
    array = np.array([corpus[i:i+num_steps+1]
                        for i in range(len(corpus)-num_steps)])
    self.X, self.Y = array[:,:-1], array[:,1:]
@d2l.add_to_class(d2l.TimeMachine)  #@save
def __init__(self, batch_size, num_steps, num_train=10000, num_val=5000):
    super(d2l.TimeMachine, self).__init__()
    self.save_hyperparameters()
    corpus, self.vocab = self.build(self._download())
    array = jnp.array([corpus[i:i+num_steps+1]
                        for i in range(len(corpus)-num_steps)])
    self.X, self.Y = array[:,:-1], array[:,1:]
@d2l.add_to_class(d2l.TimeMachine)  #@save
def __init__(self, batch_size, num_steps, num_train=10000, num_val=5000):
    super(d2l.TimeMachine, self).__init__()
    self.save_hyperparameters()
    corpus, self.vocab = self.build(self._download())
    array = tf.constant([corpus[i:i+num_steps+1]
                        for i in range(len(corpus)-num_steps)])
    self.X, self.Y = array[:,:-1], array[:,1:]

为了训练语言模型,我们将以小批量的方式随机抽样输入序列和目标序列对。以下的数据加载器每次从数据集中随机生成一个小批量。参数 batch_size 指定了每个小批量中子序列样本的数量,num_steps 是子序列的长度(以词元为单位)。

@d2l.add_to_class(d2l.TimeMachine)  #@save
def get_dataloader(self, train):
    idx = slice(0, self.num_train) if train else slice(
        self.num_train, self.num_train + self.num_val)
    return self.get_tensorloader([self.X, self.Y], train, idx)

如下所示,通过将输入序列向左移动一个词元,可以获得一个小批量的目标序列。

data = d2l.TimeMachine(batch_size=2, num_steps=10)
for X, Y in data.train_dataloader():
    print('X:', X, '\nY:', Y)
    break
Downloading ../data/timemachine.txt from http://d2l-data.s3-accelerate.amazonaws.com/timemachine.txt...
X: tensor([[10,  4,  2, 21, 10, 16, 15,  0, 20,  2],
        [21,  9,  6, 19,  0, 24,  2, 26,  0, 16]])
Y: tensor([[ 4,  2, 21, 10, 16, 15,  0, 20,  2, 10],
        [ 9,  6, 19,  0, 24,  2, 26,  0, 16,  9]])
data = d2l.TimeMachine(batch_size=2, num_steps=10)
for X, Y in data.train_dataloader():
    print('X:', X, '\nY:', Y)
    break
X: [[ 7.  7.  6. 19.  6. 15.  4.  6.  0.  3.]
 [ 6. 19.  0.  4.  2. 22.  8.  9. 21.  0.]]
Y: [[ 7.  6. 19.  6. 15.  4.  6.  0.  3.  6.]
 [19.  0.  4.  2. 22.  8.  9. 21.  0. 21.]]
[22:08:04] ../src/storage/storage.cc:196: Using Pooled (Naive) StorageManager for CPU
data = d2l.TimeMachine(batch_size=2, num_steps=10)
for X, Y in data.train_dataloader():
    print('X:', X, '\nY:', Y)
    break
X: [[13  5  0 19  6 17 19  6 20  6]
 [20 10 14 10 13  2 19 13 26  0]]
Y: [[ 5  0 19  6 17 19  6 20  6 15]
 [10 14 10 13  2 19 13 26  0 21]]
data = d2l.TimeMachine(batch_size=2, num_steps=10)
for X, Y in data.train_dataloader():
    print('X:', X, '\nY:', Y)
    break
X: tf.Tensor(
[[10 19 14 10 21 26  0 16  7  0]
 [ 0 14 16 14  6 15 21  0  4  2]], shape=(2, 10), dtype=int32)
Y: tf.Tensor(
[[19 14 10 21 26  0 16  7  0 21]
 [14 16 14  6 15 21  0  4  2 15]], shape=(2, 10), dtype=int32)

9.3.4. 总结与讨论

语言模型估计文本序列的联合概率。对于长序列,\(n\)-grams 通过截断依赖关系提供了一个方便的模型。然而,尽管存在大量结构,但频率不足以通过拉普拉斯平滑有效地处理不常见的词语组合。因此,在后续章节中,我们将专注于神经语言建模。为了训练语言模型,我们可以以小批量的方式随机抽样输入序列和目标序列对。训练后,我们将使用困惑度来衡量语言模型的质量。

随着数据大小、模型大小和训练计算量的增加,语言模型可以进行扩展。大型语言模型可以通过预测给定输入文本指令的输出文本来执行所需的任务。正如我们稍后将讨论的(例如,第 11.9 节),目前大型语言模型构成了跨越不同任务的最先进系统的基础。

9.3.5. 练习

  1. 假设训练数据集中有 100,000 个单词。一个四元语法模型需要存储多少词频和多词相邻频率?

  2. 你将如何为一个对话建模?

  3. 你还能想到哪些其他方法来读取长序列数据?

  4. 考虑我们在每个 epoch 开始时丢弃一个均匀随机数量的开头几个词元的方法。

    1. 它真的能在文档的序列上产生一个完全均匀的分布吗?

    2. 为了使分布更加均匀,你需要做什么?

  5. 如果我们希望一个序列样本是一个完整的句子,这在小批量抽样中会引入什么样的问题?我们如何解决它?