循环神经网络3-语言模型与数据集

131 阅读7分钟

1. 引言

在日常生活中,我们经常使用语言进行交流。无论是写文章、发短信,还是与朋友聊天,语言都是我们表达思想的主要工具。那么,计算机如何理解和生成自然语言呢?这就是语言模型的核心任务。本文将带你了解语言模型的基本概念、如何从数据中学习语言模型,以及如何处理长序列数据。

2. 什么是语言模型?

语言模型的目标是估计一个文本序列的联合概率。假设我们有一个文本序列,其中的词元依次为 x1,x2,,xTx_1, x_2, \dots, x_T,语言模型的任务就是计算这个序列的概率 P(x1,x2,,xT)P(x_1, x_2, \dots, x_T)

举个例子,假设我们有一个句子:“我喜欢学习深度学习”。语言模型的任务就是计算这个句子出现的概率。理想情况下,一个好的语言模型不仅能计算已有句子的概率,还能生成新的、语法正确的句子。

语言模型的应用

语言模型在自然语言处理中有广泛的应用。例如:

  • 语音识别:当语音识别系统听到“to recognize speech”和“to wreck a nice beach”时,虽然它们的发音相似,但语言模型可以帮助系统选择更合理的句子。
  • 文本生成:语言模型可以基于前面的文本生成后续的内容。例如,给定“我想吃”,模型可以生成“我想吃苹果”而不是“我想吃坦克”。

3. 如何学习语言模型?

要学习语言模型,我们需要计算单词的概率以及给定前面几个单词后出现某个单词的条件概率。假设我们有一个包含四个单词的文本序列 x1,x2,x3,x4x_1,x_2,x_3,x_4,它的概率可以表示为:

P(x1,x2,x3,x4)=P(x1)P(x2x1)P(x3x1,x2)P(x4x1,x2,x3)P(x_1,x_2,x_3,x_4) = P(x_1) \cdot P(x_2 | x_1) \cdot P(x_3 | x_1, x_2) \cdot P(x_4 | x_1,x_2,x_3)

为了计算这些概率,我们需要一个大型的文本语料库作为训练数据。例如,维基百科的所有条目或古登堡计划中的文本。

拉普拉斯平滑

在实际应用中,某些单词或单词组合可能很少出现,甚至从未出现过。为了处理这种情况,我们可以使用拉普拉斯平滑。具体方法是在所有计数中添加一个小常量 α\alpha。假设训练集中的单词总数为 NN,唯一单词的数量为 VV,则平滑后的概率可以表示为:

P(xixi1)=C(xi1,xi)+αC(xi1)+αVP(x_i \mid x_{i-1}) = \frac{C(x_{i-1}, x_i) + \alpha}{C(x_{i-1}) + \alpha V}

其中,C(xi1,xi)C(x_{i-1}, x_i)单词对 (xi1,xi)(x_{i-1}, x_i) 的出现次数,C(xi1)C(x_{i-1}) 是单词 xi1x_{i-1} 的出现次数。

4. 马尔可夫模型与 n 元语法

为了简化语言模型的计算,我们可以使用马尔可夫假设。假设当前词元只依赖于前面的 n1n-1 个词元,而不是整个历史。这种假设下的模型称为 nn 元语法模型

  • 一元语法(Unigram):每个词元独立出现,不考虑上下文。
  • 二元语法(Bigram):当前词元只依赖于前一个词元。
  • 三元语法(Trigram):当前词元依赖于前两个词元。

例如,二元语法模型可以表示为:

P(x1,x2,,xT)P(x1)P(x2x1)P(x3x2)P(xTxT1)P(x_1, x_2, \dots, x_T) \approx P(x_1) \cdot P(x_2 \mid x_1) \cdot P(x_3 \mid x_2) \cdot \dots \cdot P(x_T \mid x_{T-1})

5. 自然语言统计

我们看看在真实数据上如何进行自然语言统计。根据上一篇文章中介绍的时光机器数据集构建词表,并打印前10个最常用的(频率最高的)单词。

from pprint import pprint

import d2l

tokens = d2l.tokenize(d2l.read_time_machine())
# 因为每个文本行不一定是一个句子或一个段落,因此我们把所有文本行拼接到一起
corpus = [token for line in tokens for token in line]
vocab = d2l.Vocab(corpus)

pprint(vocab.token_freqs[:10])
[('the', 2261),
 ('i', 1267),
 ('and', 1245),
 ('of', 1155),
 ('a', 816),
 ('to', 695),
 ('was', 552),
 ('in', 541),
 ('that', 443),
 ('my', 440)]

正如我们所看到的,最常见的单词如 “the”, “i”, “and”等,通常是被称为停用词(stop words)的词汇,因此可以在某些任务中进行过滤。尽管如此,这些词汇在语言模型中依然是有意义的,并且会被使用。

5.1 词频衰减与齐普夫定律

在实际的自然语言数据中,单词的频率分布遵循齐普夫定律。齐普夫定律指出,第 ii 个最常用单词的频率 nin_i 满足:

ni1iαn_i \propto \frac{1}{i^\alpha}

其中,α\alpha 是刻画分布的指数。这意味着,最常用的单词出现的频率非常高,而大多数单词出现的频率非常低。为了更好地理解这一点,我们可以绘制词频图:

freqs = [freq for token, freq in vocab.token_freqs]
d2l.plot(freqs, xlabel='token: x', ylabel='frequency: n(x)',
         xscale='log', yscale='log')

8-3-词频衰减.png

通过这张图,我们可以看到词频在对数坐标系下迅速衰减。去掉前几个高频词后,剩余单词的频率遵循大致的直线,这表明单词的频率符合齐普夫定律(Zipf’s law)。

5.2 一元、二元和三元语法的频率

接下来,我们进一步探讨词元组合的行为。首先,构建二元语法并查看频率最高的词对:

bigram_tokens = [pair for pair in zip(corpus[:-1], corpus[1:])]
bigram_vocab = d2l.Vocab(bigram_tokens)
pprint(bigram_vocab.token_freqs[:10])

输出结果如下:

[(('of', 'the'), 309),
 (('in', 'the'), 169),
 (('i', 'had'), 130),
 (('i', 'was'), 112),
 (('and', 'the'), 109),
 (('the', 'time'), 102),
 (('it', 'was'), 99),
 (('to', 'the'), 85),
 (('as', 'i'), 78),
 (('of', 'a'), 73)]

在这 10 个最频繁的二元语法词对中,有 9 个是由两个停用词组成的,只有一个词对与 “the time” 相关。

同样,我们构建三元语法并查看其频率:

trigram_tokens = [triple for triple in zip(corpus[:-2], corpus[1:-1], corpus[2:])]
trigram_vocab = d2l.Vocab(trigram_tokens)
print(trigram_vocab.token_freqs[:10])

输出结果如下:

[(('the', 'time', 'traveller'), 59),
 (('the', 'time', 'machine'), 30),
 (('the', 'medical', 'man'), 24),
 (('it', 'seemed', 'to'), 16),
 (('it', 'was', 'a'), 15),
 (('here', 'and', 'there'), 15),
 (('seemed', 'to', 'me'), 14),
 (('i', 'did', 'not'), 14),
 (('i', 'saw', 'the'), 13),
 (('i', 'began', 'to'), 13)]

5.3 对比一元语法、二元语法和三元语法

最后,我们将一元、二元和三元语法的频率进行对比,绘制它们的词频分布:

bigram_freqs = [freq for token, freq in bigram_vocab.token_freqs]
trigram_freqs = [freq for token, freq in trigram_vocab.token_freqs]

d2l.plot([freqs, bigram_freqs, trigram_freqs],
         xlabel='token: x', ylabel='frequency: n(x)',
         xscale='log', yscale='log', legend=['unigram', 'bigram', 'trigram'])

8-3-一元二元三元词频统计.png

图像展示了三种模型的词频分布。我们观察到:

  • 除了一元语法词外,单词序列(如二元语法、三元语法)也大致符合齐普夫定律,尽管其指数值略有不同。
  • 词表中的nn元组数量相对较少,表明语言中存在大量的结构信息,这为模型的应用提供了希望。
  • 很多nn元组的出现频率较低,这使得 拉普拉斯平滑(Laplace smoothing) 在语言建模中不再适用。因此,我们转而使用基于深度学习的模型来处理这一问题。

6. 读取长序列数据

由于文本序列通常是任意长的,我们需要一种方法来处理这些长序列数据。常见的策略包括随机采样顺序分区

6.1 随机采样

在随机采样中,每个样本都是从原始长序列中随机截取的子序列。这种方法的好处是可以增加数据的多样性,但缺点是相邻的小批量数据在原始序列中可能不相邻。

以下是随机采样的代码示例:

# d2l.py

def seq_data_iter_random(corpus, batch_size, num_steps):
    """
    使用随机抽样生成一个小批量子序列。

    参数:
    corpus (list): 输入的文本数据(词元列表)。
    batch_size (int): 每个批次的样本数量。
    num_steps (int): 每个序列的长度。

    返回:
    iterator: 返回一个生成器,每次迭代返回一个批次的输入序列(X)和目标序列(Y)。
    """
    # 从随机偏移量开始对序列进行分区,随机范围包括 num_steps-1
    corpus = corpus[random.randint(0, num_steps - 1):]
    # 计算可以从文本中划分出的子序列数量,减去1是因为要考虑标签
    num_subseqs = (len(corpus) - 1) // num_steps
    # 获取每个子序列的起始索引
    initial_indices = list(range(0, num_subseqs * num_steps, num_steps))
    # 随机打乱子序列的起始索引
    random.shuffle(initial_indices)

    def data(pos):
        # 返回从 pos 位置开始的长度为 num_steps 的子序列
        return corpus[pos:pos + num_steps]

    # 计算每个批次的子序列数量
    num_batches = num_subseqs // batch_size
    # 迭代批次
    for i in range(0, batch_size * num_batches, batch_size):
        # 获取当前批次的随机起始索引
        initial_indices_per_batch = initial_indices[i:i + batch_size]
        # 生成当前批次的输入序列 X 和目标序列 Y
        X = [data(j) for j in initial_indices_per_batch]
        Y = [data(j + 1) for j in initial_indices_per_batch]

        yield torch.tensor(X), torch.tensor(Y)

接下来,假设我们想从序列 [0, 1, 2, ..., 34] 中生成一个小批量数据。我们设定 batch_size=2num_steps=5,这样可以从这个序列中随机生成多个特征-标签(X-Y)子序列对。

my_seq = list(range(35))
# 打印每次生成的输入(X)和标签(Y)
for X, Y in d2l.seq_data_iter_random(my_seq, batch_size=2, num_steps=5):
    print('X:', X, '\nY:', Y)

输出结果:

X: tensor([[ 7,  8,  9, 10, 11],
        [17, 18, 19, 20, 21]]) 
Y: tensor([[ 8,  9, 10, 11, 12],
        [18, 19, 20, 21, 22]])
X: tensor([[ 2,  3,  4,  5,  6],
        [12, 13, 14, 15, 16]]) 
Y: tensor([[ 3,  4,  5,  6,  7],
        [13, 14, 15, 16, 17]])
X: tensor([[27, 28, 29, 30, 31],
        [22, 23, 24, 25, 26]]) 
Y: tensor([[28, 29, 30, 31, 32],
        [23, 24, 25, 26, 27]])

6.2 顺序分区

在顺序分区中,我们保证相邻的小批量数据在原始序列中也是相邻的。这种方法保留了序列的顺序,适合处理需要上下文信息的任务。

以下是顺序分区的代码示例:

# d2l.py

def seq_data_iter_sequential(corpus, batch_size, num_steps):
    """使用顺序分区生成一个小批量子序列"""
    # 从随机偏移量开始划分序列
    offset = random.randint(0, num_steps)
    # 确保每个批次的大小都是batch_size的整数倍
    num_tokens = ((len(corpus) - offset - 1) // batch_size) * batch_size
    # 创建输入(Xs)和标签(Ys)序列
    Xs = torch.tensor(corpus[offset:offset + num_tokens])
    Ys = torch.tensor(corpus[offset + 1:offset + 1 + num_tokens])
    # 重塑成 (batch_size, num_steps) 的形状
    Xs, Ys = Xs.reshape(batch_size, -1), Ys.reshape(batch_size, -1)
    # 计算可以生成的批次数量
    num_batches = Xs.shape[1] // num_steps
    # 生成每个小批量的数据
    for i in range(0, num_steps * num_batches, num_steps):
        X = Xs[:, i:i + num_steps]
        Y = Ys[:, i:i + num_steps]
        yield X, Y

基于上面的实现,我们可以按照顺序分区的方式读取数据。以下是一个示例,展示了如何使用顺序分区迭代器读取每个小批量的特征 X 和标签 Y。你可以看到,相邻的小批量中的子序列确实在原始序列中是相邻的。

for X, Y in d2l.seq_data_iter_sequential(my_seq, batch_size=2, num_steps=5):
    print('X:', X, '\nY:', Y)

输出结果:

X: tensor([[ 0,  1,  2,  3,  4],
        [17, 18, 19, 20, 21]]) 
Y: tensor([[ 1,  2,  3,  4,  5],
        [18, 19, 20, 21, 22]])
X: tensor([[ 5,  6,  7,  8,  9],
        [22, 23, 24, 25, 26]]) 
Y: tensor([[ 6,  7,  8,  9, 10],
        [23, 24, 25, 26, 27]])
X: tensor([[10, 11, 12, 13, 14],
        [27, 28, 29, 30, 31]]) 
Y: tensor([[11, 12, 13, 14, 15],
        [28, 29, 30, 31, 32]])

我们将上述两种数据迭代方式(随机抽样和顺序分区)封装成一个类 SeqDataLoader,以便后续使用。

# d2l.py

class SeqDataLoader:
    """加载序列数据的迭代器"""

    def __init__(self, batch_size, num_steps, use_random_iter, max_tokens):
        # 根据选择的迭代方式设置对应的数据迭代函数
        if use_random_iter:
            self.data_iter_fn = seq_data_iter_random
        else:
            self.data_iter_fn = seq_data_iter_sequential
        # 加载数据
        self.corpus, self.vocab = load_corpus_time_machine(max_tokens)
        # 保存批大小和时间步数
        self.batch_size, self.num_steps = batch_size, num_steps

    def __iter__(self):
        return self.data_iter_fn(self.corpus, self.batch_size, self.num_steps)

最后,我们定义了一个 load_data_time_machine 函数,它返回一个数据迭代器和词表。使用这个函数可以方便地加载时光机器数据集并直接开始训练。

def load_data_time_machine(batch_size, num_steps,
                           use_random_iter=False, max_tokens=10000):
    """返回时光机器数据集的迭代器和词表"""
    data_iter = SeqDataLoader(batch_size, num_steps, use_random_iter, max_tokens)
    return data_iter, data_iter.vocab

7. 总结

语言模型是自然语言处理的核心技术之一。通过学习语言模型,我们可以估计文本序列的概率,并生成新的文本。尽管简单的统计模型如 n 元语法在处理长序列时存在局限性,但它们为我们理解语言模型提供了基础。未来,基于深度学习的语言模型将能够更好地捕捉语言的复杂结构。

  • 语言模型的目标是估计文本序列的联合概率。
  • 拉普拉斯平滑用于处理低频词和未登录词。
  • n 元语法模型通过马尔可夫假设简化了语言模型的计算。
  • 齐普夫定律描述了单词频率的分布规律。
  • 随机采样和顺序分区是处理长序列数据的两种常见策略。

希望这篇文章能帮助你理解语言模型的基本概念和应用。如果你对深度学习感兴趣,不妨继续探索更复杂的模型,如 循环神经网络(RNN)Transformer