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 ,用于表示特殊的字符,如 <unk>、<start> 等。另外,最好将语料库中的 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 = ['<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_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]])
输出:
[('<unk>', 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 个小写字母加上空格和 <unk> 共 28 个 token。这里返回的 corpus 是将整个源输入语料库中的句子全部转换为整型索引之后的序列。至此,就介绍完了最基础的 NLP 预处理过程。
Word Embedding词嵌入
之前已经介绍了 NLP 预处理的过程,得到了对应语料库中每个单词的整型数字表示,可以送入模型进行计算了。但是我们知道,与词表维度相同的整型数值等价于词表维度的 one-hot 向量表示,也就是说,在完成上述预处理之后,得到的是每个单词的 one-hot 表示。one-hot 向量来表示单词的问题很明显,一是维度过高,理论上表示每个单词会用到一个与词表大小
通常,我们会用一个 维的向量来表示一个 token,也就是说,要将 维的 one-hot 向量映射为 维的 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 的训练速度。