9--文本预处理、语言模型和数据集

发布于:2022-12-24 ⋅ 阅读:(323) ⋅ 点赞:(0)

9.1 文本预处理

        文本数据是常见的序列模型,使用序列模型对文本数据进行训练前,需要对文本进行预处理,主要是对单词进行编码,使得其能适应模型的输入。预处理的基本步骤如下:

  1. 将文本作为字符串加载到内存中。

  2. 将字符串拆分为词元(如单词和字符)。

  3. 建立一个词表,将拆分的词元映射到数字索引。

  4. 将文本转换为数字索引序列,方便模型操作。

        这里使用时光机器本文,首先读取文本内容,并对不是字母和空格的数据进行删除。

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])

#输出:
文本总数:3221
the time machine by h g wells

        将文本拆分成一个词元列表,词元(token)是文本的基本单位,最后,返回一个由词元列表组成的列表,其中的每个词元都是一个字符串(string)。

def tokenize(lines, token='word'):  #@save
    """将文本行拆分为单词或字符词元"""
    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(2):
    print(tokens[i])

#输出:
['the', 'time', 'machine', 'by', 'h', 'g', 'wells']
[]

        对所有的词构建一个字典,保存每个词以及其索引。先将训练集中的所有文档合并在一起,对它们的唯一词元进行统计, 得到的统计结果称之为语料(corpus)。然后根据每个唯一词元的出现频率,为其分配一个数字索引。很少出现的词元通常被移除,这可以降低复杂性。 另外,语料库中不存在或已删除的任何词元都将映射到一个特定的未知词元“<unk>”。 我们可以选择增加一个列表,用于保存那些被保留的词元。这里对时光机器本文来构建语料库。

class Vocab:
  def __init__(self,tokens=None,min_freq=0,reserved_tokens=None):
    if tokens is None:
      tokens=[]
    if reserved_tokens is None:
      reserved_tokens=[]
    counter = count_corpus(tokens)
    self._token_freqs = sorted(counter.items(),key=lambda x:x[1],reverse=True)
    self.idx_to_token = ['<unk>'] + reserved_tokens
    self.token_to_idx = {token:idx for idx,token in enumerate(self.idx_to_token)}
    for token, freq in self._token_freqs:
      if freq < min_freq:
        break
      if token not in self.token_to_idx:
        self.idx_to_token.append(token)
        self.token_to_idx[token] = len(self.idx_to_token)-1
  def __len__(self):
    return len(self.idx_to_token)
  def __getitem__(self,tokens):
    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_token(self,idxs):
    if not isinstance(idxs,(list,tuple)):
      return self.idx_to_token[idxs]
    return [self.to_token(idx) for idx in idxs]
  def unk(self):
    return 0 
  def token_freqs(self):
    return self._token_freqs

def count_corpus(tokens):
  if len(tokens)==0 or isinstance(tokens[0],list):
    tokens = [token for line in tokens for token in line]
  return collections.Counter(tokens)

vocab = Vocab(tokens)
print(list(vocab.token_to_idx.items())[:10])

for i in [0, 10]:
    print('文本:', tokens[i])
    print('索引:', vocab[tokens[i]])

 运行结果:

        结合以上功能实现load_corpus_time_machine函数中, 该函数返回corpus(词元索引列表)和vocab(时光机器语料库的词表。这里为了简化后面章节中的训练,使用字符(而不是单词)实现文本词元化;时光机器数据集中的每个文本行不一定是一个句子或一个段落,还可能是一个单词,因此返回的corpus仅处理为单个列表,而不是使用多词元列表构成的一个列表。

def load_corpus_time_machine(max_tokens=-1):  #@save
    """返回时光机器数据集的词元索引列表和词表"""
    lines = read_time_machine()
    tokens = tokenize(lines, 'char')
    vocab = Vocab(tokens)
    # 因为时光机器数据集中的每个文本行不一定是一个句子或一个段落,
    # 所以将所有文本行展平到一个列表中
    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)

9.2 语言模型 

        假设给定长度为 T 的文本序列 x1,x2, ... ,xT(可能是词序列,也可能是字符序列),xt (1 <= t <= T)可以被认为是文本序列在时间步 t 处的观测或者标签,语言模型的目标是估计序列的联合概率 p(x1,x2, ... xT)。

        语言模型可运用于生成文本(给定几个词,不断地对后面的词进行预测,比如那种论文续写器?)以及判断某个序列的概率(打字时的自动联想)。

        将马尔可夫模型运用于语言建模,如果P(xt+1∣xt,…,x1)=P(xt+1∣xt), 则序列上的分布满足一阶马尔可夫性质。 阶数越高,对应的依赖关系就越长,以下公式分别称为一元语法、二元语法、三元语法(即当前词与其前面多少词有依赖关系):

        语言统计,对时光机器本文中的词进行统计,并对其频率的分布进行查看。

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]
vocab = d2l.Vocab(corpus)
vocab.token_freqs[:10]
#输出:
[('the', 2261),
 ('i', 1267),
 ('and', 1245),
 ('of', 1155),
 ('a', 816),
 ('to', 695),
 ('was', 552),
 ('in', 541),
 ('that', 443),
 ('my', 440)]

        将这些词以及其频率画成词频图,可以发现词频衰减的速度相当地快,并且多数词的频率都在10以下,而频率高的,大多是一些没有具体含义的代词。

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

         将前几个单词作为例外消除后,剩余的所有单词大致遵循双对数坐标图上的一条直线。 这意味着单词的频率满足齐普夫定律(Zipf’s law), 即第i个最常用单词的频率ni为:n_i \propto \frac{1}{i^\alpha}

        等价于:\log n_i = -\alpha \log i + c,中α是刻画分布的指数(指数的大小受序列长度的影响),c是常数。 这告诉我们想要通过计数统计和平滑来建模单词是不可行的, 因为这样建模的结果会大大高估尾部单词的频率,也就是所谓的不常用单词。

        接下来查看二元语法和三元语法的情况,从输出结果来看,二元语法中高频词的词也没什么具体含义,但相比之下,三元语法的高频词与文本主题较为接近。

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

trigram_tokens = [pair for pair in zip(corpus[:-2],corpus[1:-1],corpus[2:])]
triram_vocab = d2l.Vocab(trigram_tokens)
trigram_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', '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)]

        对这三种语法的词频分布进行展示, 可以看到词表中n元组的数量并没有那么大,这说明语言中存在相当多的结构, 这些结构给了我们应用模型的希望。并且很多n元组很少出现,这使得拉普拉斯平滑非常不适合语言建模,所有考虑使用神经网络来进行建模。

        接下来考虑如何随机生成一个小批量数据的特征和标签以供读取,读取长序列的主要方式是随机采样和顺序分区。

        在随机采样中,每个样本都是在原始的长序列上任意捕获的子序列。 在迭代过程中,来自两个相邻的、随机的、小批量中的子序列不一定在原始序列上相邻。 对于语言建模,目标是基于到目前为止我们看到的词元来预测下一个词元, 因此标签是移位了一个词元的原始序列。具体代码如下:

#随机抽样生成小批量 num_steps就是tau的意思 即与前面几个词有依赖关系
def seq_data_iter_random(corpus,batch_size,num_steps):
  corpus = corpus[random.randint(0,num_steps-1):]
  num_subseqs = (len(corpus)-1)//num_steps # 减去1是因为最后一组需要一个标签
  initial_indices = list(range(0,num_subseqs*num_steps,num_steps))
  random.shuffle(initial_indices)

  def data(pos):
    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 = [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)

        在迭代过程中,除了对原始序列可以随机抽样外, 我们还可以保证两个相邻的小批量中的子序列在原始序列上也是相邻的。 这种策略在基于小批量的迭代过程中保留了拆分的子序列的顺序,因此称为顺序分区。即迭代期间来自两个相邻的小批量中的子序列在原始序列中也是相邻的。

def seq_data_iter_sequential(corpus,batch_size,num_steps):
  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+num_tokens+1])
  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

        最后对以上函数进行封装,并定义load_data_time_machine函数来同时返回数据迭代器和词表。

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)

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

网站公告

今日签到

点亮在社区的每一天
去签到