变量含义
tokens的含义:
它是tokenize()函数传入read_time_machine()函数后的返回值。其作用是对读入的文本转换成单词和字符词元,并以列表的形式进行返回。
def tokenize(lines,token='word'):
"""将文本拆分成单词或者字符词元"""
if token == 'word':
return [line.split() for line in lines]
elif token == 'char':
return [list(line) for line in lines]
else:
print('错误,未知词元类型'+token)
tokens = tokenize(lines)
for i in range(11):
print(tokens[i])
它在前一篇文章中已经提到,同时tokenize()操作也是NLP中常见的文本预处理操作,其执行结果如下
注:列表中的每一个元素都是以行为单位的,其中不是A-Z或者a-z的字符已经被转成了空格。
corpus的含义:
通过corpus操作后,文本中所有的单词已经独立称为一个列表中的元素,同时空的token已经通过遍历给过滤掉了,这里也简单做了一个demo测试,下面是一个小的测试:
其执行结果也和我们预期的是一样的,它会把其中的空元素去掉,同时合并成一个list
上述corpus操作对应的代码段如下:
# 因为每个文本行不一定是一个句子或者一个段落,因此我们把所有文本行拼接到一起
corpus = [token for line in tokens for token in line]
# 外层循环拿取行元素,内层循环从行元素中拿取不同的token(不会取出空的token)
print('前10个corpus:',corpus[:10])
其运行结果:
Vocab的含义:
它也是NLP中重要的一个部分,即建立语料库,所谓的语料库就是将每一个词元转换成一个按照词元频率降序的列表,下面也可以通过调用Vocab类中的token_freqs()属性统计每一个单词的频率并将其与词元合并成列表中的一个元素。
Vocab的类原形如下:
"""词表——构建的一个字典用来将字符串类型的词元映射到从0开始的数字索引中"""
class Vocab: #@save
"""构建词表"""
def __init__(self,tokens=None,min_freq=0,reserved_tokens=None):
if tokens is None:
tokens = []
if reserved_tokens is None:
reserved_tokens = []
# 根据词元出现的频率进行排序:降序【关键字为x的第一维度】
counter = count_corpus(tokens)
# print('counter的类型:',type(counter))
# print('counter中的内容:',counter.items())# 键值对
self.token_freqs = sorted(counter.items(), key=lambda x: x[1],
reverse=True)
self.unk,uniq_tokens = 0,['<unk>']+reserved_tokens
print('uniq_tokens的值:',uniq_tokens)
# 字典中的token
uniq_tokens += [
token for token,freq in self.token_freqs
if freq >= min_freq and token not in uniq_tokens
]
self.idx_to_token,self.token_to_idx = [],dict()
# 构造idx_to_token 的列表 并构造 token_to_idx的字典
for token in uniq_tokens:
self.idx_to_token.append(token)
self.token_to_idx[token] = len(self.idx_to_token)-1 #因为token_to_idx的下标从0开始,比idx_to_token自身长度小1
def __len__(self):
# 长度是uniq_token的个数
return len(self.idx_to_token)
def __getitem__(self, tokens):
# print('__getitem__执行了~')
#给出token返回其对应的idx
if not isinstance(tokens,(list,tuple)):
return self.token_to_idx.get(tokens,self.unk)
return [self.__getitem__(token) for token in tokens]
def to_tokens(self,indices):
# print('to_tokens执行了~')
#给出idx返回其对应的字符串token
if not isinstance(indices,(list,tuple)):
return self.idx_to_token[indices]
return [self.idx_to_token[index] for index in indices]
以下是实例化Vocab操作:
# 建立语料库
vocab = d2l.Vocab(corpus)
print('前10个vocab',list(vocab.token_to_idx.items())[:10])
# 对不同的token进行排序
print('已进行token_freqs并返回前十个元素:',vocab.token_freqs[:10])
运行结果:
词频统计
一元词频
但是这样就可以了吗?不是的,因为我们可以看到出现在频率最高的几乎都是一些无用词,例如the、I、and等,它们也通常会被称为“停用词”,因此常常会被过滤掉,但是对于模型来说它们也是很重要的。同时,我们会在运行结果中发现:‘a’后面的词频会出现衰减,并且速度也是很快的,第10个的词频已经不到第1个的
1
5
\frac{1}{5}
51
下面可以利用词频图进行观察,首先需要对我们采集每个单词的freq频率,这是通过Vocab类中提供的token_freqs属性获取的,然后再利用plot函数进行图像的描绘,scale选用Log即可,因为数值都比较大:
# 通过词频图观察:
k_values = [(token,freq) for token,freq in vocab.token_freqs]
# print(k_values)
freqs = [freq for token,freq in vocab.token_freqs]
d2l.plot(freqs,xlabel='token:x',ylabel='frequency:n(x)',xscale='log',yscale='log')
d2l.plt.show()
运行截图:
这又是一个非常有趣的事情:齐普夫定律的伟大之处又在这里体现了~ 但是刚刚接触它的人其实并不是很了解它,所以这里放了一个视频链接简单介绍了齐普夫定律:
齐普夫定律之迷——自然语言中的奇妙模式
二元语法与三元语法词频
简单的来说就是单词的频率满足的齐普夫定律就是第i个常用单词的频率 n i {n}_{i} ni可以表示为 l o g n i = α l o g i + C logn_i = αlogi + C logni=αlogi+C 其中α是刻画分布的指数,C是常数。如果根据上面的列表格式进行建模(满足齐普夫定律)后会出现一个问题:不常用单词的频率会被大大的高估,因为它们通常位于列表最前方。同时,如果不出意料的话,二元语法和三元语法也应该满足齐普夫定律。下面是对于二元、一元、三元进行了一个对比:
zip()是Python的一个内建函数,它接受一系列可迭代的对象作为参数,
将对象中对应的元素打包成一个个tuple(元组),然后返回由这些tuples组成的list(列表)。
将所有的corpus 以2为单位从前向后组成2元组
"""
bigram_tokens = [pair for pair in zip(corpus[:-1], corpus[1:])]
# print(bigram_tokens)
# bigram_tokens = [pair for pair in zip(corpus[:-1], corpus[1:])]
print('没有排序前的二元组',bigram_tokens[:10])
bigram_vocab = d2l.Vocab(bigram_tokens)
# 统计频率
print(bigram_vocab.token_freqs[:10])
"""在十个最频繁的词对中,有九个是由两个停用词组成的, 只有一个与“the time”有关。 """
"""演示三元组组成的程序"""
# print('三元组组成的程序:')
# list = [1,2,3,4,5,6,7,8,9]
# # 1 2 3 4 5 6 7 8
# # 2 3 4 5 6 7 8 9
# # 3 4 5 6 7 8 9
# trip = [trip for trip in zip(list[:-2],list[1:-1],list[2:])]
# print('演示形成的三元组:',trip)
"""进一步看看三元语法的频率是否表现出相同的行为方式"""
trigram_tokens = [triple for triple in zip(
corpus[:-2], corpus[1:-1], corpus[2:])]
trigram_vocab = d2l.Vocab(trigram_tokens)
print('形成的前10个三元组的频率',trigram_vocab.token_freqs[:10])
# 直观地对比三种模型中的词元频率:一元语法、二元语法和三元语法
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'])
d2l.plt.show()
在上述代码中,其实最有趣的是二元和三元的一个组合方式,它们是通过巧妙应用列表下标实现的,下面是一个实现的图:
上述小结:
- 除了一元语法词,单词序列似乎也遵循齐普夫定律, 尽管公式 (8.3.7)中的指数α更小 (指数的大小受序列长度的影响);
- 词表中n元组的数量并没有那么大,这说明语言中存在相当多的结构, 这些结构给了我们应用模型的希望;
- 很多n元组很少出现,这使得拉普拉斯平滑非常不适合语言建模。 作为代替,我们将使用基于深度学习的模型。
读取长序列数据
假设我们将使用神经网络来训练语言模型, 模型中的网络一次处理具有预定义长度 (例如n个时间步)的一个小批量序列。但在分割文本时不同的偏移量会导致划分出不同的子序列,在训练模型时将会采用随机偏移量的方式开始划分子序列。以获得覆盖性和随机性。包括随机采样和顺序分区、
随机采样
在随机采样中,每个样本都是在原始的长序列上任意捕获的子序列。 在迭代过程中,来自两个相邻的、随机的、小批量中的子序列不一定在原始序列上相邻。 对于语言建模,目标是基于到目前为止我们看到的词元来预测下一个词元, 因此标签是移位了一个词元的原始序列。
至于yield关键字的作用可以参考这一篇文章:
你常看到 Python 代码中的 yield 到底是什么鬼?
下面是实现的代码以及测试的代码:
"""------------------随机采样------------------"""
"""在随机采样中,每个样本都是在原始的长序列上任意捕获的子序列。
在迭代过程中,来自两个相邻的、随机的、小批量中的子序列不一定在
原始序列上相邻。 对于语言建模,目标是基于到目前为止我们看到的
词元来预测下一个词元, 因此标签是移位了一个词元的原始序列。"""
"""该函数对于原始的corpus的改变仅仅体现在使用了corpus = corpus[random.randint(0,num_steps-1):]
将corpus整体向后移动一个随机的位置
"""
def seq_data_iter_random(corpus,batch_size,num_steps): #@save
"""使用随机抽样生成一个小批量的子序列"""
# 从随机偏移量开始对序列进行分区,随机范围包括num_step-1
# 偏移量随机产生于区间[0,num_steps-1]中
corpus = corpus[random.randint(0,num_steps-1):]
# 减去1是为了考虑标签 这样得到的是每个子序列开始的位置
num_subseqs = (len(corpus)-1) // num_steps
print('len(corpus)-1:',len(corpus)-1)
# corpus 为1000个词元
# 设 num_steps 为5 corpus[3:]
# num_subseqs = 999/5 = 199
# num_subseqs 可以认为是每个子序列的个数
"""从0到len(corpus)-1,步幅为num_steps的所有词元索引【每个子序列开始的位置】"""
initial_indices = list(range(0, num_subseqs * num_steps, num_steps))
print(f'initial_indices的类型:{type(initial_indices)}')
# 在随机抽样的迭代过程中,来自于两个相邻的、随机的、小批量的子序列不一定在原始序列中也相邻
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包含子序列的随机起始索引
# 从0到num_subseqs 以每次batch_size的步幅向前移动
initial_indices_per_batch = initial_indices[i:i+batch_size]
"""利用for循环每次取batchsize个initial_indices_per_batch"""
"""并将initial_indices_per_batch作为下标 去corpus中读取具体数据"""
"""Y获取的是X的下一个小批量数据"""
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到34的序列。 假设批量大小为2,时间步数为5,这意味着可以生成下取整[(35-1)/5] = 6个
“特征-标签”子序列对。 如果设置小批量大小为2,我们只能得到3个小批量。
"""
my_seq = list(range(35))
for X,Y in seq_data_iter_random(my_seq,batch_size=2,num_steps=5):
print(f'X:{X},\nY:{Y}')
顺序分区
在迭代过程中,除了对原始序列可以随机抽样外, 我们还可以保证两个相邻的小批量中的子序列
在原始序列上也是相邻的。 这种策略在基于小批量的迭代过程中保留了拆分的子序列的顺序,因此称
为顺序分区。
其中需要注意的是offset起到了corpus后移的作用,主要用于记录其后移的单元个数。而num_steps依旧是步长(上面写的是步幅)
"""------------------顺序分区------------------"""
"""在迭代过程中,除了对原始序列可以随机抽样外, 我们还可以保证两个相邻的小批量中的子序列
在原始序列上也是相邻的。 这种策略在基于小批量的迭代过程中保留了拆分的子序列的顺序,因此称
为顺序分区。
"""
def seq_data_iter_sequential(corpus, batch_size, num_steps): #@save
"""使用顺序分区生成一个小批量子序列"""
# 从随机偏移量开始划分序列
offset = random.randint(0,num_steps)
# tokens的个数发生了变化
# tokens的个数等于总的cortus长度减去offset值-1【-1是求出坐标】
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+num_tokens+1]))
Xs,Ys = Xs.reshape(batch_size,-1),Ys.reshape(batch_size,-1)
# batches的个数 : Xs和Ys被按照(batch_size)按照列放入矩阵后,再除以num_steps就是每次拿出几个列 作为batches
num_batches = Xs.shape[1]//num_steps
# debug:num_batches = Xs.size(1)
# print(num_batches)
# 以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)
将上面的两个采样函数包装到一个类中
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
省流版:整合代码
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]
# 外层循环拿取行元素,内层循环从行元素中拿取不同的token (不会取出空的token)
# 建立语料库
vocab = d2l.Vocab(corpus)
# 对不同的token进行排序
print('已进行token_freqs并返回前十个元素:',vocab.token_freqs[:10])
"""出现的问题
1、 频率最高的词往往是一些无用的词,例如the、I、and等,它们通常被称为停用词,因此可以被过滤掉
但是尽管如此,它们本身仍然还是很重要的,后面会在模型中使用它们。
2、词频衰减过快,最常用单词的词频对比,第10个还不到第1个的1/5
"""
# 通过词频图观察:
k_values = [(token,freq) for token,freq in vocab.token_freqs]
# print(k_values)
freqs = [freq for token,freq in vocab.token_freqs]
d2l.plot(freqs,xlabel='token:x',ylabel='frequency:n(x)',xscale='log',yscale='log')
d2l.plt.show()
"""单词的频率满足齐普夫定律(Zipf’s law)"""
"""即第i个常用单词的 频率ni可表示为 logni = - αlogi + c
其中α是刻画分布的指数
c是常数。
这样建模后不常用词的频率会被大大高估 二元语法和三元语法的频率与一元语法的频率表现相同的方式
zip()是Python的一个内建函数,它接受一系列可迭代的对象作为参数,
将对象中对应的元素打包成一个个tuple(元组),然后返回由这些tuples组成的list(列表)。
将所有的corpus 以2为单位从前向后组成2元组
"""
bigram_tokens = [pair for pair in zip(corpus[:-1], corpus[1:])]
# print(bigram_tokens)
# bigram_tokens = [pair for pair in zip(corpus[:-1], corpus[1:])]
print('没有排序前的二元组',bigram_tokens[:10])
bigram_vocab = d2l.Vocab(bigram_tokens)
# 统计频率
print(bigram_vocab.token_freqs[:10])
"""在十个最频繁的词对中,有九个是由两个停用词组成的, 只有一个与“the time”有关。 """
"""演示三元组组成的程序"""
# print('三元组组成的程序:')
# list = [1,2,3,4,5,6,7,8,9]
# # 1 2 3 4 5 6 7 8
# # 2 3 4 5 6 7 8 9
# # 3 4 5 6 7 8 9
# trip = [trip for trip in zip(list[:-2],list[1:-1],list[2:])]
# print('演示形成的三元组:',trip)
"""进一步看看三元语法的频率是否表现出相同的行为方式"""
trigram_tokens = [triple for triple in zip(
corpus[:-2], corpus[1:-1], corpus[2:])]
trigram_vocab = d2l.Vocab(trigram_tokens)
print('形成的前10个三元组的频率',trigram_vocab.token_freqs[:10])
# 直观地对比三种模型中的词元频率:一元语法、二元语法和三元语法
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'])
d2l.plt.show()
"""
上述小结:
1、除了一元语法词,单词序列似乎也遵循齐普夫定律, 尽管公式 (8.3.7)中的指数α更小 (指数的大小受序列长度的影响);
2、词表中n元组的数量并没有那么大,这说明语言中存在相当多的结构, 这些结构给了我们应用模型的希望;
3、很多n元组很少出现,这使得拉普拉斯平滑非常不适合语言建模。 作为代替,我们将使用基于深度学习的模型。
"""
"""----------------------------读取长序列数据----------------------------"""
# 假设我们将使用神经网络来训练语言模型, 模型中的网络一次处理具有预定义长度 (例如n个时间步)的一个小批量序列
# 在分割文本时不同的偏移量会导致划分出不同的子序列
# 在训练模型时将会采用随机偏移量的方式开始划分子序列。以获得覆盖性和随机性
"""------------------随机采样------------------"""
"""在随机采样中,每个样本都是在原始的长序列上任意捕获的子序列。
在迭代过程中,来自两个相邻的、随机的、小批量中的子序列不一定在
原始序列上相邻。 对于语言建模,目标是基于到目前为止我们看到的
词元来预测下一个词元, 因此标签是移位了一个词元的原始序列。"""
"""该函数对于原始的corpus的改变仅仅体现在使用了corpus = corpus[random.randint(0,num_steps-1):]
将corpus整体向后移动一个随机的位置
"""
def seq_data_iter_random(corpus,batch_size,num_steps): #@save
"""使用随机抽样生成一个小批量的子序列"""
# 从随机偏移量开始对序列进行分区,随机范围包括num_step-1
# 偏移量随机产生于区间[0,num_steps-1]中
corpus = corpus[random.randint(0,num_steps-1):]
# 减去1是为了考虑标签
num_subseqs = (len(corpus)-1) // num_steps
# corpus 为1000个词元
# 设 num_steps 为5 corpus[3:]
# num_subseqs = 999/5 = 199
# num_subseqs 可以认为是划分了几块
"""从0到len(corpus)-1,步幅为num_steps的所有词元索引"""
initial_indices = list(range(0, num_subseqs * num_steps, num_steps))
print(f'initial_indices的类型:{type(initial_indices)}')
# 在随机抽样的迭代过程中,来自于两个相邻的、随机的、小批量的子序列不一定在原始序列中也相邻
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包含子序列的随机起始索引
# 从0到num_subseqs 以每次batch_size的步幅向前移动
initial_indices_per_batch = initial_indices[i:i+batch_size]
"""利用for循环每次取batchsize个initial_indices_per_batch"""
"""并将initial_indices_per_batch作为下标 去corpus中读取具体数据"""
"""Y获取的是X的下一个小批量数据"""
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到34的序列。 假设批量大小为2,时间步数为5,这意味着可以生成下取整[(35-1)/5] = 6个
“特征-标签”子序列对。 如果设置小批量大小为2,我们只能得到3个小批量。
"""
my_seq = list(range(35))
for X,Y in seq_data_iter_random(my_seq,batch_size=2,num_steps=5):
print(f'X:{X},\nY:{Y}')
"""------------------顺序分区------------------"""
"""在迭代过程中,除了对原始序列可以随机抽样外, 我们还可以保证两个相邻的小批量中的子序列
在原始序列上也是相邻的。 这种策略在基于小批量的迭代过程中保留了拆分的子序列的顺序,因此称
为顺序分区。
"""
def seq_data_iter_sequential(corpus, batch_size, num_steps): #@save
"""使用顺序分区生成一个小批量子序列"""
# 从随机偏移量开始划分序列
offset = random.randint(0,num_steps)
# tokens的个数发生了变化
# tokens的个数等于总的cortus长度减去offset值-1【-1是求出坐标】
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+num_tokens+1]))
Xs,Ys = Xs.reshape(batch_size,-1),Ys.reshape(batch_size,-1)
# batches的个数 : Xs和Ys被按照(batch_size)按照列放入矩阵后,再除以num_steps就是每次拿出几个列 作为batches
num_batches = Xs.shape[1]//num_steps
# debug:num_batches = Xs.size(1)
# print(num_batches)
# 以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)
"""------------------将上面的两个采样函数包装到一个类中, 以便稍后可以将其用作数据迭代器。------------------"""
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