对于序列数据处理问题,数据存在许多种形式,文本是最常见例子之一。 例如,一篇文章可以被简单地看作一串单词序列,甚至是一串字符序列。 本节中,我们将解析文本的常见预处理步骤。 这些步骤通常包括:
import collections
import re
from d2l import torch as d2l
首先,我们从H.G.Well的时光机器中加载文本。 这是一个相当小的语料库,只有30000多个单词,但足够我们小试牛刀, 而现实中的文档集合可能会包含数十亿个单词。 下面的函数(将数据集读取到由多条文本行组成的列表中),其中每条文本行都是一个字符串。 为简单起见,我们在这里忽略了标点符号和字母大写。
d2l.DATA_HUB['time_machine'] = (d2l.DATA_URL + 'timemachine.txt',
'090b5e7e70c295757f55df93cb0a180b9691891a')
def read_time_machine():
"""将时间机器数据集加载到文本行的列表中"""
with open(d2l.download('time_machine'), 'r') as f:
lines = f.readlines()
return [re.sub('[^A-Za-z]+', ' ', line).strip().lower() for line in lines]
lines = read_time_machine()
print(f'# 文本总行数: {len(lines)}')
print(lines[0])
print(lines[10])
运行结果:
下面的tokenize
函数将文本行列表(lines)作为输入, 列表中的每个元素是一个文本序列(如一条文本行)。 每个文本序列又被拆分成一个词元列表,词元(token)
是文本的基本单位。 最后,返回一个由词元列表组成的列表,其中的每个词元都是一个字符串(string)
。
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])
运行结果:
词元的类型是字符串,而模型需要的输入是数字,因此这种类型不方便模型使用。
现在,让我们构建一个字典,通常也叫做词表(vocabulary), 用来将字符串类型的词元映射到从 0 开始的数字索引中。
我们先将训练集中的所有文档合并在一起,对它们的唯一词元进行统计, 得到的统计结果称之为语料(corpus)。 然后根据每个唯一词元的出现频率,为其分配一个数字索引。 很少出现的词元通常被移除,这可以降低复杂性。 另外,语料库中不存在或已删除的任何词元都将映射到一个特定的未知词元“”。 我们可以选择增加一个列表,用于保存那些被保留的词元, 例如:填充词元(“”); 序列开始词元(“”); 序列结束词元(“”)。
python的sorted函数
class Vocab:
"""文本词表"""
# 如果一个token出现的次数小于min_freq,我就丢弃这个token
# reserved_tokens是被保留的词元,如句子开始的token,句子结束的token
def __init__(self, tokens=None, min_freq=0, reserved_tokens=None):
if tokens is None: # 如果没有token,就为空
tokens = []
if reserved_tokens is None: # 如果没有reserved_tokens,也为空
reserved_tokens = []
# 按出现频率排序
counter = count_corpus(tokens) # counter是一个字典
# items()方法把字典中每对key和value组成一个元组,并把这些元组放在列表中返回,但整体是一个dict_items对象
# 不过如果用 for循环遍历,如 for i in ... 还是能得到每个元组
# 例如:dict_items([('apple',3),('banana',4)])
# sorted可以对所有可迭代类型进行排序,并且返回新的已排序的列表。
# sorted(iterable, cmp=None, key=None, reverse=False) --> new sorted list
# 1. 可迭代参数,例如字典、列表,2.比较函数,
# 3. 可迭代类型中某个属性,对给定元素的每一项进行排序,
# 4. false 升序,true降序
self._token_freqs = sorted(counter.items(), key=lambda x: x[1],
reverse=True)
# 未知词元是'',索引为0,其余的被保留的词元,索引依次递增
# 不过默认reserved_tokens=None,包括后面调用函数也没有更改这个参数的值
# 所以在这个语料库中只有,没有等
# print([''] + ['',''])
# ['', '', '']
self.idx_to_token = ['' ] + reserved_tokens
self.token_to_idx = {token: idx
for idx, token in enumerate(self.idx_to_token)}
# 拿到token以及出现频率
for token, freq in self._token_freqs:
if freq < min_freq: # 如果出现频率小于min_freq,跳出循环
# 因为已经按降序排列,找到了出现次数小于min_freq的,这个以及之后的词元都不需要了
break
if token not in self.token_to_idx:
# 如果不属于未知词元或者保留词元,出现频率也大于等于min_freq的词元
# 其实就是一些文本中真正的单词,例如之前的'apple'、'banana'等类似
self.idx_to_token.append(token) # 将有意义的词元加入到 idx_to_token这个列表中
# 换言之,idx_to_token这个列表保存了所有我需要的token,知道idx就得到token:
# 先是'unk',再是reserved_tokens,再是'apple'、'banana'等
# 所以例如:idx_to_token[0]='unk',idx_to_token[1]='bos',idx_to_token[2]='eos'
# idx_to_token[3]='banana' 等等
# 因此它的长度len(self.idx_to_token) 就是词元列表的长度
self.token_to_idx[token] = len(self.idx_to_token) - 1 # 并且给这个词元一个索引
# 但是token_to_idx是一个字典,token为key,idx为value:
# 因此给一个token,是返回index,所以"self.token_to_idx[token] = 。。" 就是在重写index
def __len__(self):
# 计算得到unique token的个数,去重之后的token的个数
# 因为相同的token我已经计算了出现次数
return len(self.idx_to_token)
# 给一个token tuple 或者 list of token,返回index
def __getitem__(self, tokens):
if not isinstance(tokens, (list, tuple)):
# get()方法,是根据key返回value,如果指定键不存在,返回第二个参数
# 第二个参数默认是None,在这里传入了unk
# 也就是说 :遇到不认识的token就返回0作为index
return self.token_to_idx.get(tokens, self.unk)
# ps:这里用了递归,具体解释如下:
# 假设tokens是一个数组['the','time','machine']
# 遍历时先获取第1个token,即 'the',然后再进入函数__getitem__('the')
# 因为'the'既不是元组,也不是list,而是字符串,所以会进入if语句
# 就会传入token_to_idx.get('the')函数,根据'the'这个key 返回它的index
return [self.__getitem__(token) for token in tokens]
# 给一些index,把对应的token返回
def to_tokens(self, indices):
if not isinstance(indices, (list, tuple)):
return self.idx_to_token[indices]
return [self.idx_to_token[index] for index in indices]
@property
def unk(self): # 未知词元的索引为0
return 0
@property
def token_freqs(self):
return self._token_freqs
def count_corpus(tokens):
"""统计词元的频率"""
# 这里的tokens是1D列表或2D列表
if len(tokens) == 0 or isinstance(tokens[0], list):
# 将词元列表展平成一个列表
tokens = [token for line in tokens for token in line]
# collections.Counter:给一堆token,计算每一个token出现的次数
# 返回一个字典,如{'apple':3,'banana':4}
return collections.Counter(tokens)
我们首先使用时光机器数据集作为语料库来构建词表,然后打印前几个高频词元及其索引。
vocab = Vocab(tokens)
# items()方法把字典中每对key和value组成一个元组,并把这些元组放在列表中返回,
# 再作为一个整体成为dict_item对象
print(list(vocab.token_to_idx.items())[:10])
运行结果:
现在,我们可以(将每一条文本行转换成一个数字索引列表)。
for i in [0, 10]:
# 分别大于第1句话和第10句话以及对应的数字索引
print('文本:', tokens[i])
# vocab[]默认调用__getitem__函数
# 因为__getitem__就是重载了[]运算符
print('索引:', vocab[tokens[i]])
__ getitem__的作用是什么呢:如果在类中定义了__getitem__()方法,那么它的实例对象(假设为P)就可以这样P[key]取值。当实例对象做P[key]运算时,就会调用__getitem__()方法。
运行结果:
在使用上述函数时,我们将所有功能打包到load_corpus_time_machine
函数中, 该函数返回corpus(词元索引列表)和vocab(时光机器语料库的词表)。 我们在这里所做的改变是:
def load_corpus_time_machine(max_tokens=-1):
"""返回时光机器数据集的词元索引列表和词表"""
lines = read_time_machine()
# ps:这里是按字符分,那么假设list('the time machine')
# 则会变成['t','h','e',' ','t','i',....,'e']
tokens = tokenize(lines, 'char')
vocab = Vocab(tokens)
# 因为时光机器数据集中的每个文本行不一定是一个句子或一个段落,
# 所以将所有文本行展平到一个列表中
# ps:下面这个二层for循环是如下:
# for line in tokens: //取出每一行
# for token in line :// 再取出每一行的字符
# corpus.append(vocab[token]) 再把每一个字符对应的下标加到corpus中
corpus = [vocab[token] for line in tokens for token in line]
if max_tokens > 0:
corpus = corpus[:max_tokens]
return corpus, vocab
corpus, vocab = load_corpus_time_machine()
len(corpus), len(vocab)
# len(corpus)=170580 表示这篇文章共有170580个字符
# 为什么len(vocab)是28呢?
# 答: char是字母,16个字母++空格 = 28
运行结果: