NLP数据预处理与词嵌入

NLP数据预处理与词嵌入

NLP数据预处理

读入语料库

首先准备一个语料库,实际上就是一个 txt 文件,这里用的是小说 time machine ,该语料库比较短小,仅有 ~3000 行,~30000 词,比较适合作为 toy data 练手。我们先把它读进来,并用正则表达式将除了字母之外的字符都转换为空格,再把字母全都转换为小写。实际中当然不会这么暴力地处理源文本,这里简单起见这样操作,如此整个文本就只有 26 个小写字母和空格组成。

import re
def read_time_machine():
    with open('timemachine.txt', 'r') as f:
        lines = f.readlines()
    data_lines = [re.sub('[^A-Za-z]+', ' ', line).strip().lower() for line in lines]
    return data_lines

输出:

# 文本总行数:3221
the time machine by h g wells
twinkled and his usually pale face was flushed and animated the

可以看到,读入的文本共 3221 行,每行是一个文本序列。

tokenize:划分token

在读入文本之后,得到的是一行一行的数据。下一步就是进行 tokenize,将整段/整行的文本划分为一个个的 token。在英文中,最简单的划分 token 的单位有按词、按字符,在最新的 NLP 模型中,一般用 BPE 编码,按 subword 进行划分,可参考 深入理解NLP Subword算法:BPE、WordPiece、ULM。这里先采用最简单的按词/按字符划分。在中文中一般是按字符(方块字)划分或者按词划分,一个不同之处在于中文按词划分时需要自行进行分词,因为中文的表达习惯中词语之间没有天然的空格间隔。一般可以使用 jieba 等中文分词工具。

def tokenize(lines, token_type='word'):
    if token_type == 'word':
        return [line.split() for line in lines]
    elif token_type == 'char':
        return [list(line) for line in lines]
    else:
        print('错误:未知token类型:', token_type)
tokens = tokenize(lines)
for i in range(11):
    print(tokens[i])

输出:

['the', 'time', 'machine', 'by', 'h', 'g', 'wells']
[]
[]
[]
[]
['i']
[]
[]
['the', 'time', 'traveller', 'for', 'so', 'it', 'will', 'be', 'convenient', 'to', 'speak', 'of', 'him']
['was', 'expounding', 'a', 'recondite', 'matter', 'to', 'us', 'his', 'grey', 'eyes', 'shone', 'and']
['twinkled', 'and', 'his', 'usually', 'pale', 'face', 'was', 'flushed', 'and', 'animated', 'the']

在按词划分 token 完成后,每一句话就表示成了一堆词语组成的列表。

构建Vocab

Vocab 也是 NLP 预处理中的一个重要概念。由于 NLP 模型在进行训练、推理时,都是接收数值型数据进行处理,而目前读取到的语料和划分的 token 都是字符串。因此,在划分 token 之后,将每个 token(词/字符)映射到一个从 0 开始表示的整型索引。出了语料库中出现的 token 之外,还可能需要一些保留 token ,用于表示特殊的字符,如 等。另外,最好将语料库中的 token 按照词频排序,将更常被访问到的单词放在列表前面,虽然这在算法上不是必须的,但可以改善缓存命中率,在一定程度上提高模型的效率。通常我们在使用别人的预训练模型参数时,出了模型权重文件,还需要训练时的 vocab 词表,否则单词与模型认识整型索引不对应的话,就全都乱套了。

from collections import Counter

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

class Vocab:
    def __init__(self, tokens=None, reserved_tokens=None, min_freq=0):
        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 = [''] + 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_tokens(self, indices):
        if not isinstance(indices, (list, tuple)):
            return self.idx_to_token[indices]
        return [self.to_tokens(idx) for idx in indices]

    @property
    def unk(self):
        return 0		# 未知token索引为0

    @property
    def token_freqs(self):
        return self._token_freqs

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

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

输出:

[('', 0), ('the', 1), ('i', 2), ('and', 3), ('of', 4), ('a', 5), ('to', 6), ('was', 7), ('in', 8), ('that', 9)]
文本:  ['the', 'time', 'machine', 'by', 'h', 'g', 'wells']
索引:  [1, 19, 50, 40, 2183, 2184, 400]
文本:  ['twinkled', 'and', 'his', 'usually', 'pale', 'face', 'was', 'flushed', 'and', 'animated', 'the']
索引:  [2186, 3, 25, 1044, 362, 113, 7, 1421, 3, 1045, 1]

构建了 token 到索引和索引到 token 的两个映射。可以看到,借助 vocab 可以将一句话(字符串序列)转换为整型序列,这就可以将它输入给模型了。

完整过程

整个预处理过程封装如下:

def load_corpus_time_machine(max_tokens=-1):
    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()
print(len(corpus), len(vocab))

输出:

170580 28

这里我们按照字符来进行 token 的划分,这样 26 个小写字母加上空格和 共 28 个 token。这里返回的 corpus 是将整个源输入语料库中的句子全部转换为整型索引之后的序列。至此,就介绍完了最基础的 NLP 预处理过程。

Word Embedding词嵌入

之前已经介绍了 NLP 预处理的过程,得到了对应语料库中每个单词的整型数字表示,可以送入模型进行计算了。但是我们知道,与词表维度相同的整型数值等价于词表维度的 one-hot 向量表示,也就是说,在完成上述预处理之后,得到的是每个单词的 one-hot 表示。one-hot 向量来表示单词的问题很明显,一是维度过高,理论上表示每个单词会用到一个与词表大小 N V N_V NV 相同维度的向量;二是单词之间的关系无法表达,比如 cat 和 dog 语义比较接近,cat 和 car 应该比较远离,但是用 one-hot 向量无法表达出这种关系。

通常,我们会用一个 D D D 维的向量来表示一个 token,也就是说,要将 N V N_V NV 维的 one-hot 向量映射为 D D D 维的 word embedding,词嵌入。从转换形式上来看,只需要一个 N V × D N_V\times D NV×D 的线性变换就可以实现 word embedding 的过程。现在的问题就是,怎么确定这个转换矩阵。

确定这个转换矩阵的思路有两种,一是随着整个特定任务(如文本分类等)进行端到端的学习,就是将转换矩阵看作一个线性层,随任务一起训练,更新参数。第二种就是各种单独的 word embedding 的方式了,将在下一节介绍。

Word Embedding常见方法

单独的 Word Embedding ,与第一种端到端、随任务一起训练的思路区分开来,是一些无监督的方法。无监督的方法不需要任何人工标注的数据,而是根据原始文本数据来学习单词之间的关系。词嵌入在深度模型中的作用是为下游任务(如文本分类等)提供输入特征。常见的方法有:TF-IDF, Word2Vec, GloVe, FastText, ELMO, CoVe, BERT, RoBERTa。这些方法可以分为两大类,上下文无关的和上下文相关的。

以下是 5分钟 NLP系列—— 11 个词嵌入模型总结 总结的常见词嵌入方法。笔者认为,只要是以自监督的形式,为每个 token 学习特定的表征向量的,都可以认为是 Word Embedding 方法。 不管是早期的 Word2Vec 还是最近的 BERT,RoBERTa 等。

  • 上下文无关
    • 不需要学习
      • Bag-of-Words
      • TF-IDF
    • 需要学习
      • Word2Vec
      • GloVe
      • FastText
  • 上下文相关
    • 基于 RNN
      • ELMO
      • CoVe
    • 基于 Transformers
      • BERT
      • XLM
      • RoBERTa

与上下文无关

这类模型学习到的表征的特点是,在不考虑单词上下文的情况下,每个单词都是独特的和不同的。

不需要学习

Bag-of-words(词袋):一个文本(如一个句子或一个文档)被表示为它的词袋,不考虑语法、词序。

TF-IDF:通过获取词的频率(TF)并乘以词的逆文档频率(IDF)来得到这个分数。

需要进行学习

Word2Vec:经过训练以重建单词的语言上下文的浅层(两层)神经网络。 Word2vec 可以利用两种模型架构中的任何一种:连续词袋 (CBOW) 或连续skip-gram。 在 CBOW 架构中,模型从周围上下文词的窗口中预测当前词。 在连续skip-gram架构中,模型使用当前词来预测上下文的周围窗口。

GloVe(Global Vectors for Word Representation):训练是在语料库中汇总的全局单词-单词共现统计数据上执行的,结果表示显示了单词向量空间的线性子结构。

FastText:与 GloVe 不同,它通过将每个单词视为由字符 n-gram 组成而不是整个单词来嵌入单词。 此功能使其不仅可以学习生僻词,还可以学习词汇表外的词。

上下文相关

与上下文无关的词嵌入不同,上下文相关的方法根据其上下文为同一个词学习不同的嵌入表示。

基于 RNN

ELMO(Embeddings from Language Model):使用基于字符的编码层和两个 BiLSTM 层的神经语言模型来学习上下文化的词表示,可以学习情景化的单词表示。

CoVe(Contextualized Word Vectors):使用深度 LSTM 编码器,该编码器来自经过机器翻译训练的注意力seq2seq模型,将单词向量上下文化。

基于Transformers

BERT(Bidirectional Encoder Representations from Transformers):在大型跨域语料库上训练的基于Transformers的语言表示模型。并使用掩码语言模型来预测序列中随机被遮蔽的单词,还通过下一句预测任务,用于学习句子之间的关联。

XLM(Cross-lingual Language Model):一种基于单语言语种的非监督方法来学习跨语种表示的跨语言模型,通过将不同语言放在一起采用新的训练目标进行训练,从而让模型能够掌握更多的跨语言信息。

RoBERTa (Robustly Optimized BERT Pretraining Approach):它建立在 BERT 之上并修改了关键超参数,移除了下一句预训练目标,并以更大的小批量和学习率进行训练。

ALBERT(A Lite BERT for Self-supervised Learning of Language Representations):它提出了参数减少技术,以降低内存消耗并提高 BERT 的训练速度。

  • 52 文本预处理【动手学深度学习v2】
  • 深入理解NLP Subword算法:BPE、WordPiece、ULM
  • 5分钟 NLP系列—— 11 个词嵌入模型总结

你可能感兴趣的:(自然语言处理,自然语言处理,人工智能)