[Paddle2.0学习之第四步]词向量之CBOW
项目已放在aistudio: [Paddle2.0学习之第四步](下)词向量之CBOW
整体跟上一篇的skip-gram类似可以速看此篇。
CBOW的算法实现
对比Skip-gram,CBOW和Skip-gram的算法实现如 图1 所示。本项目将补充CBOW的算法实现过程
图1:CBOW和Skip-gram的算法实现
如 图1 所示,CBOW是一个具有3层结构的神经网络,分别是:
- Input Layer(输入层):接收one-hot张量 V∈R1×vocab_sizeV \in R^{1 \times \text{vocab\_size}}V∈R1×vocab_size 作为网络的输入,里面存储着当前句子中上下文单词的one-hot表示。
- Hidden Layer(隐藏层):将张量VVV乘以一个word embedding张量W1∈Rvocab_size×embed_sizeW^1 \in R^{\text{vocab\_size} \times \text{embed\_size}}W1∈Rvocab_size×embed_size,并把结果作为隐藏层的输出,得到一个形状为R1×embed_sizeR^{1 \times \text{embed\_size}}R1×embed_size的张量,里面存储着当前句子上下文的词向量。
- Output Layer(输出层):将隐藏层的结果乘以另一个word embedding张量W2∈Rembed_size×vocab_sizeW^2 \in R^{\text{embed\_size} \times \text{vocab\_size}}W2∈Rembed_size×vocab_size,得到一个形状为R1×vocab_sizeR^{1 \times \text{vocab\_size}}R1×vocab_size的张量。这个张量经过softmax变换后,就得到了使用当前上下文对中心的预测结果。根据这个softmax的结果,我们就可以去训练词向量模型。
在实际操作中,使用一个滑动窗口(一般情况下,长度是奇数),从左到右开始扫描当前句子。每个扫描出来的片段被当成一个小句子,每个小句子中间的词被认为是中心词,其余的词被认为是这个中心词的上下文。
CBOW算法和skip-gram算法最本质的区别就是:CBOW算法是以上下文预测中心词,而skip-gram算法是以中心城预测上下文。
CBOW的理想实现
使用神经网络实现CBOW中,模型接收的输入应该有2个不同的tensor:
-
代表当前上下文的tensor:假设我们称之为context_words VVV,一般来说,这个tensor是一个形状为[batch_size, vocab_size]的one-hot tensor,表示在一个mini-batch中,每组上下文中每一个单词的ID。 -
代表目标词的tensor:假设我们称之为target_words TTT,一般来说,这个tensor是一个形状为[batch_size, 1]的整型tensor,这个tensor中的每个元素是一个[0, vocab_size-1]的值,代表目标词的ID。
在理想情况下,我们可以这样实现CBOW:把上下文中的每一个单词,依次作为输入,把当前句子中的中心词作为标签,构建神经网络进行学习,实现上下文预测中心词。具体过程如下:
- 声明一个形状为[vocab_size, embedding_size]的张量,作为需要学习的词向量,记为W0W_0W0?。对于给定的输入VVV,即某一个上下文的单词,使用向量乘法,将VVV乘以W0W_0W0?,这样就得到了一个形状为[batch_size, embedding_size]的张量,记为H=V?W0H=V*W_0H=V?W0?。这个张量HHH就可以看成是经过词向量查表后的结果。
- 声明另外一个需要学习的参数W1W_1W1?,这个参数的形状为[embedding_size, vocab_size]。将上一步得到的HHH去乘以W1W_1W1?,得到一个新的tensor O=H?W1O=H*W_1O=H?W1?,此时的OOO是一个形状为[batch_size, vocab_size]的tensor,表示当前这个mini-batch中的每一组上下文中的每一个单词预测出的目标词的概率。
- 使用softmax函数对mini-batch中每个中心词的预测结果做归一化,即可完成网络构建。
CBOW的实际实现
和课程中讲解的skip-gram一样,在实际中,为避免过于庞大的计算量,我们通常采用负采样的方法,来避免查询整个此表,从而将多分类问题转换为二分类问题。具体实现过程如图2:
图2 CBOW算法的实际实现
在实现的过程中,通常会让模型接收3个tensor输入:
-
代表上下文单词的tensor:假设我们称之为context_words VVV,一般来说,这个tensor是一个形状为[batch_size, vocab_size]的one-hot tensor,表示在一个mini-batch中每个中心词具体的ID。 -
代表目标词的tensor:假设我们称之为target_words TTT,一般来说,这个tensor同样是一个形状为[batch_size, vocab_size]的one-hot tensor,表示在一个mini-batch中每个目标词具体的ID。 -
代表目标词标签的tensor:假设我们称之为labels LLL,一般来说,这个tensor是一个形状为[batch_size, 1]的tensor,每个元素不是0就是1(0:负样本,1:正样本)。
模型训练过程如下:
- 首先遍历上下文,得到上下文中的一个单词,用VVV(上下文)去查询W0W_0W0?,用TTT(目标词)去查询W1W_1W1?,分别得到两个形状为[batch_size, embedding_size]的tensor,记为H1H_1H1?和H2H_2H2?。
- 点乘这两个tensor,最终得到一个形状为[batch_size]的tensor O=[Oi=∑jH0[i,j]?H1[i,j]]i=1batch_sizeO = [O_i = \sum_j H_0[i,j] * H_1[i,j]]_{i=1}^{batch\_size}O=[Oi?=∑j?H0?[i,j]?H1?[i,j]]i=1batch_size?。
- 使用随即负采样得到一些负样本(0),同时以目标词作为正样本(1),输入值标签信息label。
- 使用sigmoid函数作用在OOO上,将上述点乘的结果归一化为一个0-1的概率值,作为预测概率,根据标签信息label训练这个模型即可。
2. 使用paddle2.0实现CBOW
接下来我们将学习使用飞桨实现CBOW模型的方法。在飞桨中,不同深度学习模型的训练过程基本一致,流程如下:
-
数据处理:选择需要使用的数据,并做好必要的预处理工作。 -
网络定义:使用飞桨定义好网络结构,包括输入层,中间层,输出层,损失函数和优化算法。 -
网络训练:将准备好的数据送入神经网络进行学习,并观察学习的过程是否正常,如损失函数值是否在降低,也可以打印一些中间步骤的结果出来等。 -
网络评估:使用测试集合测试训练好的神经网络,看看训练效果如何。
import io
import os
import sys
import requests
from collections import OrderedDict
import math
import random
import numpy as np
import paddle
from paddle.nn import Embedding
import paddle.nn.functional as F
/opt/conda/envs/python35-paddle120-env/lib/python3.7/site-packages/paddle/fluid/layers/utils.py:26: DeprecationWarning: `np.int` is a deprecated alias for the builtin `int`. To silence this warning, use `int` by itself. Doing this will not modify any behavior and is safe. When replacing `np.int`, you may wish to use e.g. `np.int64` or `np.int32` to specify the precision. If you wish to review your current use, check the release note link for additional information.
Deprecated in NumPy 1.20; for more details and guidance: https://numpy.org/devdocs/release/1.20.0-notes.html#deprecations
def convert_to_list(value, n, name, dtype=np.int):
2.1 数据处理
首先,找到一个合适的语料用于训练word2vec模型。使用text8数据集,这个数据集里包含了大量从维基百科收集到的英文语料,我们可以通过如下代码下载数据集,下载后的文件被保存在当前目录的“text8.txt”文件内。
def readdata():
corpus_url = "data/data98805/text8.txt"
with open(corpus_url, "r") as f:
corpus = f.read().strip("\n")
print(corpus)
f.close()
return corpus
corpus = readdata()
IOPub data rate exceeded.
The notebook server will temporarily stop sending output
to the client in order to avoid crashing it.
To change this limit, set the config variable
`--NotebookApp.iopub_data_rate_limit`.
Current values:
NotebookApp.iopub_data_rate_limit=1000000.0 (bytes/sec)
NotebookApp.rate_limit_window=3.0 (secs)
corpus[:250]
' anarchism originated as a term of abuse first used against early working class radicals including the diggers of the english revolution and the sans culottes of the french revolution whilst the term is still used in a pejorative way to describe any '
一般来说,在自然语言处理中,需要先对语料进行切词。对于英文来说,可以比较简单地直接使用空格进行切词,代码如下:
def data_preprocess(corpus):
corpus = corpus.strip().lower()
corpus = corpus.split(" ")
return corpus
corpus = data_preprocess(corpus)
corpus[:10]
['anarchism',
'originated',
'as',
'a',
'term',
'of',
'abuse',
'first',
'used',
'against']
在经过切词后,需要对语料进行统计,为每个词构造ID。一般来说,可以根据每个词在语料中出现的频次构造ID,频次越高,ID越小,便于对词典进行管理。代码如下:
def build_dict(corpus):
word_freq_dict = dict()
for word in corpus:
if word not in word_freq_dict:
word_freq_dict[word] = 0
word_freq_dict[word] += 1
word_freq_dict = sorted(word_freq_dict.items(), key = lambda x:x[1], reverse = True)
word2id_dict = dict()
word2id_freq = dict()
id2word_dict = dict()
for word, freq in word_freq_dict:
curr_id = len(word2id_dict)
word2id_dict[word] = curr_id
word2id_freq[word2id_dict[word]] = freq
id2word_dict[curr_id] = word
return word2id_freq, word2id_dict, id2word_dict
word2id_freq, word2id_dict, id2word_dict = build_dict(corpus)
vocab_size = len(word2id_freq)
print("there are totoally %d different words in the corpus" % vocab_size)
for _, (word, word_id) in zip(range(10), word2id_dict.items()):
print("word %s, its id %d, its word freq %d" % (word, word_id, word2id_freq[word_id]))
there are totoally 253854 different words in the corpus
word the, its id 0, its word freq 1061396
word of, its id 1, its word freq 593677
word and, its id 2, its word freq 416629
word one, its id 3, its word freq 411764
word in, its id 4, its word freq 372201
word a, its id 5, its word freq 325873
word to, its id 6, its word freq 316376
word zero, its id 7, its word freq 264975
word nine, its id 8, its word freq 250430
word two, its id 9, its word freq 192644
得到word2id词典后,还需要进一步处理原始语料,把每个词替换成对应的ID,便于神经网络进行处理,代码如下:
def convert_corpus_to_id(corpus, word2id_dict):
corpus = [word2id_dict[word] for word in corpus]
return corpus
corpus = convert_corpus_to_id(corpus, word2id_dict)
print("%d tokens in the corpus" % len(corpus))
print(corpus[:20])
17005207 tokens in the corpus
[5233, 3080, 11, 5, 194, 1, 3133, 45, 58, 155, 127, 741, 476, 10571, 133, 0, 27349, 1, 0, 102]
接下来,需要使用二次采样法处理原始文本。二次采样法的主要思想是降低高频词在语料中出现的频次。方法是随机将高频的词抛弃,频率越高,被抛弃的概率就越大;频率越低,被抛弃的概率就越小。标点符号或冠词这样的高频词就会被抛弃,从而优化整个词表的词向量训练效果,代码如下:
def subsampling(corpus, word2id_freq):
def discard(word_id):
return random.uniform(0, 1) < 1 - math.sqrt(
1e-4 / word2id_freq[word_id] * len(corpus))
corpus = [word for word in corpus if not discard(word)]
return corpus
corpus = subsampling(corpus, word2id_freq)
print("%d tokens in the corpus" % len(corpus))
print(corpus[:20])
8742408 tokens in the corpus
[5233, 3080, 3133, 155, 741, 476, 10571, 133, 27349, 102, 854, 15067, 58112, 150, 854, 3580, 190, 10712, 214, 6]
在完成语料数据预处理之后,需要构造训练数据。根据上面的描述,我们需要使用一个滑动窗口对语料从左到右扫描,在每个窗口内,中心词需要预测它的上下文,并形成训练数据。
在实际操作中,由于词表往往很大(50000,100000等),对大词表的一些矩阵运算(如softmax)需要消耗巨大的资源,因此可以通过负采样的方式模拟softmax的结果。
- 给定一个中心词和一个需要预测的上下文词,把这个上下文词作为正样本。
- 通过词表随机采样的方式,选择若干个负样本。
- 把一个大规模分类问题转化为一个2分类问题,通过这种方式优化计算速度。
def build_data(corpus, word2id_dict, word2id_freq, max_window_size = 3, negative_sample_num = 4):
dataset = []
center_word_idx=0
while center_word_idx < len(corpus):
window_size = random.randint(1, max_window_size)
positive_word = corpus[center_word_idx]
context_word_range = (max(0, center_word_idx - window_size), min(len(corpus) - 1, center_word_idx + window_size))
context_word_candidates = [corpus[idx] for idx in range(context_word_range[0], context_word_range[1]+1) if idx != center_word_idx]
for context_word in context_word_candidates:
dataset.append((context_word, positive_word, 1))
i = 0
while i < negative_sample_num:
negative_word_candidate = random.randint(0, vocab_size-1)
if negative_word_candidate is not positive_word:
dataset.append((context_word, negative_word_candidate, 0))
i += 1
center_word_idx = min(len(corpus) - 1, center_word_idx + window_size)
if center_word_idx == (len(corpus) - 1):
center_word_idx += 1
return dataset
corpus_light = corpus[:int(len(corpus)*0.2)]
dataset = build_data(corpus_light, word2id_dict, word2id_freq)
for _, (center_word, target_word, label) in zip(range(25), dataset):
print("center_word %s, target %s, label %d" % (id2word_dict[center_word],
id2word_dict[target_word], label))
center_word originated, target anarchism, label 1
center_word originated, target hockfield, label 0
center_word originated, target entrusts, label 0
center_word originated, target qutn, label 0
center_word originated, target unscholarly, label 0
center_word anarchism, target originated, label 1
center_word anarchism, target iuppiter, label 0
center_word anarchism, target fust, label 0
center_word anarchism, target bos, label 0
center_word anarchism, target scowcroft, label 0
center_word abuse, target originated, label 1
center_word abuse, target gyfarch, label 0
center_word abuse, target pustet, label 0
center_word abuse, target alek, label 0
center_word abuse, target ethology, label 0
center_word anarchism, target abuse, label 1
center_word anarchism, target hangars, label 0
center_word anarchism, target grundschule, label 0
center_word anarchism, target jaswant, label 0
center_word anarchism, target tenable, label 0
center_word originated, target abuse, label 1
center_word originated, target bearshistory, label 0
center_word originated, target neurotoxins, label 0
center_word originated, target chbosky, label 0
center_word originated, target laurajean, label 0
训练数据准备好后,把训练数据都组装成mini-batch,并准备输入到网络中进行训练,代码如下:
def build_batch(dataset, batch_size, epoch_num):
context_word_batch = []
target_word_batch = []
label_batch = []
eval_word_batch = []
for epoch in range(epoch_num):
random.shuffle(dataset)
for context_word, target_word, label in dataset:
context_word_batch.append([context_word])
target_word_batch.append([target_word])
label_batch.append(label)
if len(eval_word_batch) == 0:
eval_word_batch.append([word2id_dict['one']])
elif len(eval_word_batch) == 1:
eval_word_batch.append([word2id_dict['king']])
elif len(eval_word_batch) ==2:
eval_word_batch.append([word2id_dict['who']])
if len(context_word_batch) == batch_size:
yield epoch,\
np.array(context_word_batch).astype("int64"),\
np.array(target_word_batch).astype("int64"),\
np.array(label_batch).astype("float32"),\
np.array(eval_word_batch).astype("int64")
context_word_batch = []
target_word_batch = []
label_batch = []
eval_word_batch = []
if len(context_word_batch) > 0:
yield epoch,\
np.array(context_word_batch).astype("int64"),\
np.array(target_word_batch).astype("int64"),\
np.array(label_batch).astype("float32"),\
np.array(eval_word_batch).astype("int64")
for _, batch in zip(range(10), build_batch(dataset, 128, 3)):
print(batch)
2.2 定义CBOW网络结构
定义CBOW的网络结构,用于模型训练。在飞桨动态图中,对于任意网络,都需要定义一个继承自paddle.nn.layer的类来搭建网络结构、参数等数据的声明。同时需要在forward函数中定义网络的计算逻辑。值得注意的是,我们仅需要定义网络的前向计算逻辑,飞桨会自动完成神经网络的后向计算。
在CBOW的网络结构中,使用的最关键的APi是paddle.nn.Embedding函数,可以用其实现Embedding的网络层。通过查询飞桨的API文档,可以得到如下更详细的说明:
paddle.nn.Embedding(numembeddings, embeddingdim, paddingidx=None, sparse=False, weightattr=None, name=None)
该接口用于构建 Embedding 的一个可调用对象,其根据input中的id信息从embedding矩阵中查询对应embedding信息,并会根据输入的size (numembeddings, embeddingdim)自动构造一个二维embedding矩阵。 输出Tensor的shape是在输入Tensor shape的最后一维后面添加了emb_size的维度。注:input中的id必须满足 0 =< id < size[0],否则程序会抛异常退出。
class SkipGram(paddle.nn.Layer):
def __init__(self, vocab_size, embedding_size, init_scale=0.1):
super(SkipGram, self).__init__()
self.vocab_size = vocab_size
self.embedding_size = embedding_size
self.embedding = Embedding(
num_embeddings = self.vocab_size,
embedding_dim = self.embedding_size,
weight_attr=paddle.ParamAttr(
initializer=paddle.nn.initializer.Uniform(
low=-init_scale, high=init_scale)))
self.embedding_out = Embedding(
num_embeddings = self.vocab_size,
embedding_dim = self.embedding_size,
weight_attr=paddle.ParamAttr(
initializer=paddle.nn.initializer.Uniform(
low=-init_scale, high=init_scale)))
def forward(self, context_words, target_words, label, eval_words):
context_words_emb = self.embedding(context_words)
target_words_emb = self.embedding_out(target_words)
eval_words_emb = self.embedding(eval_words)
word_sim = paddle.multiply(context_words_emb, target_words_emb)
word_sim = paddle.sum(word_sim, axis=-1)
word_sim = paddle.reshape(word_sim, shape=[-1])
pred = F.sigmoid(word_sim)
loss = F.binary_cross_entropy_with_logits(word_sim, label)
loss = paddle.mean(loss)
return pred, loss
2.3 网络训练
完成网络定义后,就可以启动模型训练。我们定义每隔100步打印一次Loss,以确保当前的网络是正常收敛的。
同时,我们每隔10000步观察一下skip-gram计算出来的同义词(使用 embedding的乘积),可视化网络训练效果,代码如下:
运行时长: 4小时15分钟8秒301毫秒
batch_size = 512
epoch_num = 3
embedding_size = 200
step = 0
learning_rate = 0.001
def get_similar_tokens(query_token, k, embed):
W = embed.numpy()
x = W[word2id_dict[query_token]]
cos = np.dot(W, x) / np.sqrt(np.sum(W * W, axis=1) * np.sum(x * x) + 1e-9)
flat = cos.flatten()
indices = np.argpartition(flat, -k)[-k:]
indices = indices[np.argsort(-flat[indices])]
for i in indices:
print('for word %s, the similar word is %s' % (query_token, str(id2word_dict[i])))
paddle.set_device('gpu:0')
skip_gram_model = SkipGram(vocab_size, embedding_size)
adam = paddle.optimizer.Adam(learning_rate=learning_rate, parameters = skip_gram_model.parameters())
for epoch_num, context_words, target_words, label, eval_words in build_batch(
dataset, batch_size, epoch_num):
context_words_var = paddle.to_tensor(context_words)
target_words_var = paddle.to_tensor(target_words)
label_var = paddle.to_tensor(label)
eval_words_var = paddle.to_tensor(eval_words)
pred, loss = skip_gram_model(
context_words_var, target_words_var, label_var, eval_words_var)
loss.backward()
adam.step()
adam.clear_grad()
step += 1
if step % 1000 == 0:
print("step %d, loss %.3f" % (step, loss.numpy()[0]))
if step % 10000 ==0:
get_similar_tokens('movie', 5, skip_gram_model.embedding.weight)
get_similar_tokens('one', 5, skip_gram_model.embedding.weight)
get_similar_tokens('who', 5, skip_gram_model.embedding.weight)
从打印结果可以看到,经过一定步骤的训练,Loss逐渐下降并趋于稳定。
同时也可以发现CBOW模型可以学习到一些有趣的语言现象。
总结
CBOW提供了一种根据上下文推理中心词的思路。
比如在多数情况下,“香蕉”和“橘子”更加相似,而“香蕉”和“句子”就没有那么相似;同时,“香蕉”和“食物”、“水果”的相似程度可能介于“橘子”和“句子”之间。那么如何让存储的词向量具备这样的语义信息呢?
我们先学习自然语言处理领域的一个小技巧。在自然语言处理研究中,科研人员通常有一个共识:使用一个单词的上下文来了解这个单词的语义,比如:
“苹果手机质量不错,就是价格有点贵。”
“这个苹果很好吃,非常脆。”
“菠萝质量也还行,但是不如苹果支持的APP多。”
在上面的句子中,我们通过上下文可以推断出第一个“苹果”指的是苹果手机,第二个“苹果”指的是水果苹果,而第三个“菠萝”指的应该也是一个手机。事实上,在自然语言处理领域,使用上下文描述一个词语或者元素的语义是一个常见且有效的做法。我们可以使用同样的方式训练词向量,让这些词向量具备表示语义信息的能力。
全网同名: iterhui
我在AI Studio上获得钻石等级,点亮9个徽章,来互关呀~
https://aistudio.baidu.com/aistudio/personalcenter/thirdview/643467
|