系列文章目录
文章目录
- 系列文章目录
- 理论部分
- 使用计数来建模
- N元语法
- 总结
- 代码
- 读取长序列数据
- 随机采样
- 顺序分区
- 小结
- 练习
理论部分
在上一部分中,我们了解了如何将文本数据映射为词元,以及将这些词元可以视为一系列离散的观测,例如单词或字符。
假设长度为
T
T
T的文本序列中的词元依次为
x
1
,
x
2
,
…
,
x
T
x_1, x_2, \ldots, x_T
x1,x2,…,xT。于是,
x
t
x_t
xt(
1
≤
t
≤
T
1 \leq t \leq T
1≤t≤T)可以被认为是文本序列在时间步
t
t
t处的观测或标签。在给定这样的文本序列时,语言模型(language model)的目标是估计序列的联合概率
P ( x 1 , x 2 , … , x T ) . P(x_1, x_2, \ldots, x_T). P(x1,x2,…,xT).
例如,只需要一次抽取一个词元 x t ∼ P ( x t ∣ x t − 1 , … , x 1 ) x_t \sim P(x_t \mid x_{t-1}, \ldots, x_1) xt∼P(xt∣xt−1,…,x1),一个理想的语言模型就能够基于模型本身生成自然文本。与猴子使用打字机完全不同的是,从这样的模型中提取的文本都将作为自然语言(例如,英语文本)来传递。只需要基于前面的对话片断中的文本,就足以生成一个有意义的对话。显然,我们离设计出这样的系统还很遥远,因为它需要“理解”文本,而不仅仅是生成语法合理的内容。
尽管如此,语言模型依然是非常有用的。例如,短语“to recognize speech”和“to wreck a nice beach”读音上听起来非常相似。
这种相似性会导致语音识别中的歧义,但是这很容易通过语言模型来解决,因为第二句的语义很奇怪。同样,在文档摘要生成算法中,“狗咬人”比“人咬狗”出现的频率要高得多,或者“我想吃奶奶”是一个相当匪夷所思的语句,而“我想吃,奶奶”则要正常得多。
使用计数来建模
N元语法
总结
- 语言模型估计文本序列的联合概率
- 使用统计方法时常采用n元语法
代码
import random
import torch
from d2l import torch as d2l
tokens = d2l.tokenize(d2l.read_time_machine())
# 因为每个文本行不一定是一个句子或一个段落,因此我们把所有文本行拼接到一起
corpus = [token for line in tokens for token in line]
#print(corpus)
vocab = d2l.Vocab(corpus)
print(vocab.token_freqs[:10])
print(list(vocab.token_to_idx.items())[:10])
[('the', 2261), ('i', 1267), ('and', 1245), ('of', 1155), ('a', 816), ('to', 695), ('was', 552), ('in', 541), ('that', 443), ('my', 440)]
[('<unk>', 0), ('the', 1), ('i', 2), ('and', 3), ('of', 4), ('a', 5), ('to', 6), ('was', 7), ('in', 8), ('that', 9)]
正如我们所看到的,(最流行的词)看起来很无聊,这些词通常(被称为停用词)(stop words),比如the、and、of,因此可以被过滤掉。
尽管如此,它们本身仍然是有意义的,我们仍然会在模型中使用它们。
此外,还有个明显的问题是词频衰减的速度相当地快。
例如,最常用单词的词频对比,第
10
10
10个还不到第
1
1
1个的
1
/
5
1/5
1/5。
为了更好地理解,我们可以[画出的词频图]:
freqs = [freq for token, freq in vocab.token_freqs] # 把频率提取出来放到一个list中
d2l.plot(freqs, xlabel='token: x', ylabel='frequency: n(x)',
xscale='log', yscale='log')
通过此图我们可以发现:词频以一种明确的方式迅速衰减。
将前几个单词作为例外消除后,剩余的所有单词大致遵循双对数坐标图上的一条直线。
这意味着单词的频率满足齐普夫定律(Zipf’s law),
即第
i
i
i个最常用单词的频率
n
i
n_i
ni为:
n
i
∝
1
i
α
,
n_i \propto \frac{1}{i^\alpha},
ni∝iα1,
:eqlabel:eq_zipf_law
等价于
log n i = − α log i + c , \log n_i = -\alpha \log i + c, logni=−αlogi+c,
其中
α
\alpha
α是刻画分布的指数,
c
c
c是常数。
这告诉我们想要通过计数统计和平滑来建模单词是不可行的,
因为这样建模的结果会大大高估尾部单词的频率,也就是所谓的不常用单词。
那么[其他的词元组合,比如二元语法、三元语法等等,又会如何呢?]
我们来看看二元语法的频率是否与一元语法的频率表现出相同的行为方式。
bigram_tokens = [pair for pair in zip(corpus[:-1], corpus[1:])]
bigram_vocab = d2l.Vocab(bigram_tokens)
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)]
这里值得注意:在十个最频繁的词对中,有九个是由两个停用词组成的,
只有一个与“the time”有关。
我们再进一步看看三元语法的频率是否表现出相同的行为方式。
trigram_tokens = [triple for triple in zip(
corpus[:-2], corpus[1:-1], corpus[2:])]
trigram_vocab = d2l.Vocab(trigram_tokens)
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)]
最后,我们[直观地对比三种模型中的词元频率]:一元语法、二元语法和三元语法。
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'])
这张图非常令人振奋!原因有很多:
- 除了一元语法词,单词序列似乎也遵循齐普夫定律,
尽管公式 :eqref:eq_zipf_law
中的指数 α \alpha α更小
(指数的大小受序列长度的影响); - 词表中
n
n
n元组的数量并没有那么大,这说明语言中存在相当多的结构,
这些结构给了我们应用模型的希望; - 很多
n
n
n元组很少出现,这使得拉普拉斯平滑非常不适合语言建模。
作为代替,我们将使用基于深度学习的模型。
读取长序列数据
由于序列数据本质上是连续的,因此我们在处理数据时需要解决这个问题。
我们以一种相当特别的方式做到了这一点:当序列变得太长而不能被模型一次性全部处理时,我们可能希望拆分这样的序列方便模型读取。
在介绍该模型之前,我们看一下总体策略。
假设我们将使用神经网络来训练语言模型,模型中的网络一次处理具有预定义长度(例如
n
n
n个时间步)的一个小批量序列。
现在的问题是如何[随机生成一个小批量数据的特征和标签以供读取。]
首先,由于文本序列可以是任意长的,例如整本《时光机器》(The Time Machine),于是任意长的序列可以被我们划分为具有相同时间步数的子序列。
当训练我们的神经网络时,这样的小批量子序列将被输入到模型中。
假设网络一次只处理具有
n
n
n个时间步的子序列。下图中画出了从原始文本序列获得子序列的所有不同的方式,其中
n
=
5
n=5
n=5,并且每个时间步的词元对应于一个字符。
请注意,因为我们可以选择任意偏移量来指示初始位置,所以我们有相当大的自由度。
因此,我们应该从上图中选择哪一个呢?事实上,他们都一样的好。
然而,如果我们只选择一个偏移量,那么用于训练网络的、所有可能的子序列的覆盖范围将是有限的。因为定住一个偏移量后,相当于从上图中选择一行(即一种情况)来反复使用,在一个epoch中每个子序列(数据)可能要被使用多次(比如一个epoch要循环两遍文章数据),不如使用随即偏移量,来使用更多种不同数据。
因此,我们可以从随机偏移量开始划分序列,以同时获得覆盖性(coverage)和随机性(randomness)。
下面,我们将描述如何实现随机采样(random sampling)和]顺序分区(sequential partitioning)策略。
随机采样
(在随机采样中,每个样本都是在原始的长序列上任意捕获的子序列。)
在迭代过程中,来自两个相邻的、随机的、小批量中的子序列不一定在原始序列上相邻。
对于语言建模,目标是基于到目前为止我们看到的词元来预测下一个词元,因此标签是移位了一个词元的原始序列。
下面的代码每次可以从数据中随机生成一个小批量。
在这里,参数batch_size
指定了每个小批量中子序列样本的数目,参数num_steps
是每个子序列中预定义的时间步数。
def seq_data_iter_random(corpus, batch_size, num_steps): #@save
"""使用随机抽样生成一个小批量子序列"""
# 从随机偏移量开始对序列进行分区,随机范围包括num_steps-1
corpus = corpus[random.randint(0, num_steps - 1):]
# 减去1,是因为我们需要考虑标签
num_subseqs = (len(corpus) - 1) // num_steps
# 长度为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包含子序列的随机起始索引
initial_indices_per_batch = initial_indices[i: i + batch_size]
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)
X: tensor([[21, 22, 23, 24, 25],
[11, 12, 13, 14, 15]])
Y: tensor([[22, 23, 24, 25, 26],
[12, 13, 14, 15, 16]])
X: tensor([[ 6, 7, 8, 9, 10],
[16, 17, 18, 19, 20]])
Y: tensor([[ 7, 8, 9, 10, 11],
[17, 18, 19, 20, 21]])
X: tensor([[ 1, 2, 3, 4, 5],
[26, 27, 28, 29, 30]])
Y: tensor([[ 2, 3, 4, 5, 6],
[27, 28, 29, 30, 31]])
顺序分区
在迭代过程中,除了对原始序列可以随机抽样外,我们还可以[保证两个相邻的小批量中的子序列在原始序列上也是相邻的]。
这种策略在基于小批量的迭代过程中保留了拆分的子序列的顺序,因此称为顺序分区。
def seq_data_iter_sequential(corpus, batch_size, num_steps): #@save
"""使用顺序分区生成一个小批量子序列"""
# 从随机偏移量开始划分序列
offset = random.randint(0, num_steps)
num_tokens = ((len(corpus) - offset - 1) // batch_size) * batch_size
Xs = torch.tensor(corpus[offset: offset + num_tokens])
Ys = torch.tensor(corpus[offset + 1: offset + 1 + num_tokens])
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 seq_data_iter_sequential(my_seq, batch_size=2, num_steps=5):
print('X: ', X, '\nY:', Y)
X: tensor([[ 1, 2, 3, 4, 5],
[17, 18, 19, 20, 21]])
Y: tensor([[ 2, 3, 4, 5, 6],
[18, 19, 20, 21, 22]])
X: tensor([[ 6, 7, 8, 9, 10],
[22, 23, 24, 25, 26]])
Y: tensor([[ 7, 8, 9, 10, 11],
[23, 24, 25, 26, 27]])
X: tensor([[11, 12, 13, 14, 15],
[27, 28, 29, 30, 31]])
Y: tensor([[12, 13, 14, 15, 16],
[28, 29, 30, 31, 32]])
现在,我们[将上面的两个采样函数包装到一个类中],以便稍后可以将其用作数据迭代器。
class SeqDataLoader: #@save
"""加载序列数据的迭代器"""
def __init__(self, batch_size, num_steps, use_random_iter, max_tokens):
if use_random_iter:
self.data_iter_fn = d2l.seq_data_iter_random
else:
self.data_iter_fn = d2l.seq_data_iter_sequential
self.corpus, self.vocab = d2l.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
,它同时返回数据迭代器和词表],因此可以与其他带有load_data
前缀的函数 类似地使用。
def load_data_time_machine(batch_size, num_steps, #@save
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
小结
- 语言模型是自然语言处理的关键。
- n n n元语法通过截断相关性,为处理长序列提供了一种实用的模型。
- 长序列存在一个问题:它们很少出现或者从不出现。
- 齐普夫定律支配着单词的分布,这个分布不仅适用于一元语法,还适用于其他 n n n元语法。
- 通过拉普拉斯平滑法可以有效地处理结构丰富而频率不足的低频词词组。
- 读取长序列的主要方式是随机采样和顺序分区。在迭代过程中,后者可以保证来自两个相邻的小批量中的子序列在原始序列上也是相邻的。
练习
- 假设训练数据集中有 100 , 000 100,000 100,000个单词。一个四元语法需要存储多少个词频和相邻多词频率?
- 我们如何对一系列对话建模?
- 一元语法、二元语法和三元语法的齐普夫定律的指数是不一样的,能设法估计么?
- 想一想读取长序列数据的其他方法?
- 考虑一下我们用于读取长序列的随机偏移量。
- 为什么随机偏移量是个好主意?
- 它真的会在文档的序列上实现完美的均匀分布吗?
- 要怎么做才能使分布更均匀?
- 如果我们希望一个序列样本是一个完整的句子,那么这在小批量抽样中会带来怎样的问题?如何解决?