TensorFlow与自然语言处理应用
上QQ阅读APP看书,第一时间看更新

4.2 Word2vec模型(以Skip-Gram为例)

Mikolov等人在2013年的文献中,同时提出了Skip-Gram和CBOW(Continuous Bagof-Words)模型,设计两个模型的主要目的是希望用更高效的算法获取词向量。因此,根据前人在NNLM、RNNLM和C&W模型上的积累,他们简化了当时已有模型且保留了核心部分,便有了这两个模型。常见的CBOW和Skip-Gram模型结构图如图4-10所示。

图4-10 模型结构图:左侧CBOW模型,右侧Skip-Gram模型

Skip-Gram算法模型是一种利用文字的上下文来学习词嵌入的算法。让我们在后文一步一步地去理解Skip-Gram算法。因为原始的Skip-Gram模型没有中间隐藏层,而目前使用的优化Skip-Gram模型存在中间隐藏层,为了更好地解读Skip-Gram模型,所以这里先讨论优化Skip-Gram模型,后面会对原始Skip-Gram和优化Skip-Gram做一个简单对比分析。

4.2.1 直观认识Word2vec

上面提到的词向量(Word2vec)是词的数字表示,保留了词之间的语义关系。例如,单词cat的向量与单词dog的向量就非常相似,而单词pencil的向量却与词cat的向量差别很大,甚至完全不同。其实,这种词向量间的相似性取决于对应的频率,所以这里讨论一下两个词(如[cat,dog]或者[pencil,cat])在相同上下文中的使用情况。考虑以下句子:

很显然,在组成上面句子的过程中,无论是cat还是dog,都比pencil更加适合填充到空白处。在上面的句子中,pencil无论从拼写还是语法方面都没有问题,为什么它就是不对呢?是因为这里的pencil单词会使得上下文语义产生错误。所以,Word2vec算法模型使用词的上下文来学习词的数字表示,以便在相同上下文中所使用的词具有相似的词向量。

4.2.2 定义任务

我们现在解读一下Word2vec算法的机制情况,后面会继续讲解模型细节,以便我们知道如何实现Word2vec算法。为了以无监督方式学习词向量(数据没有被标记),我们需要定义且完成一些任务,具体任务有:

(1)创建格式为[输入词,输出词]的数据元组,其中每个词都表示为一个One-Hot向量,均来自原始文本。

(2)定义出一个模型,将One-Hot向量作为输入和输出进行训练。

(3)定义一个损失函数,用于预测正确的词(这些词来自于输入词的上下文),以便优化模型。

(4)通过相似词具有相似的词向量来评估模型。

这个流程看似简单,其实在学习词向量的表现上是超强的,下面对相关细节进行解读。

4.2.3 从原始文本创建结构化数据

这部分对于原始文本的操作并不复杂,只是将其置于某个结构中。下面给出一个例子,这里有下面一句话:

    The cat pushed the glass off the table.

由上面这句话,我们创建的数据结构如图4-11所示。句子下面的每一行代表一个数据点。蓝色框表示One-Hot输入词(中间词,也叫目标词),红色框表示One-Hot输出词(上下文窗口中除中间词之外的任何词均称为上下文词)。上下文窗口大小越大,模型的性能就越好。随着数据量的增加,窗口的大小也随之增大,进一步导致计算成本快速上升。注意一点,不要将目标词与神经网络的目标(正确输出)混淆,这是两个完全不同的东西。本节内容暂以Skip-Gram模型为例进行解释说明。

图4-11 例句数据结构示意图

这里,单个上下文的窗口是指从当前输入词(Input Word,即目标词)的一侧(左边或右边)选取词的数量。

如果我们设置窗口大小window_size=2,那么我们最终获得窗口宽度为5,窗口内容就是“'The','cat','pushed', 'the','glass'”。

注意

网上有专栏文章多处指出,整个窗口大小是2*window_size=4,笔者为此查阅多篇国外原文资料,最终认为整个窗口宽度或大小应该为span=2*window_size+1,有疑惑的地方,建议读者多看几篇国外原文,因为也有个别国外文章中提到span=2*window_size,误导了读者。

4.2.4 定义词嵌入层和神经网络

1. 词嵌入层(Embedding Layer,也称为嵌入层):存储所有词向量

词嵌入层存储词汇表中找到的所有词的词向量。你可以想象到这将是一个巨大的矩阵([vocabulary size × embedding size],即[词汇大小×词向量大小])。这里读者可以自行调整词向量大小(Embedding Size)。当然,词向量大小越大,模型表现越佳。结合上面的例句,其流程图如图4-12所示。

图4-12 词向量示例图

2. 神经网络:将词向量映射到输出

在训练期间,神经网络利用输入词去尝试预测输出词。然后,我们会惩罚模型的错误分类以及奖励模型的正确分类。这里对模型会话做了一些限制:一次处理单个输入和单个输出。下面是训练期间的流程:

(1)对于给定的输入词(目标词),从词嵌入层中找到相应的词向量。

(2)将词向量输入神经网络,然后尝试预测正确的输出词(上下文词)。

(3)通过比较预测和真实的上下文词,计算损失。

(4)利用损失函数和随机优化器来优化神经网络和词嵌入层。

需要注意的一点是,在计算预测时,我们使用softmax激活函数将预测标准化为有效的概率分布。

4.2.5 整合

下面我们可以将所有部分放在一起来看模型的全貌,如图4-13所示。

这种数据的局部排列和模型布局是Word2vec的Skip-Gram算法,也是我们本节要关注的内容。另一种算法称为连续词袋(CBOW)模型,后面也会单独阐述。

至此,我们可以对于Skip-Gram模型的概念结构和实施结构做个小的总结。图4-14所示为概念结构图,图4-15所示为实施结构图。

图4-13 模型全貌示例

图4-14 Skip-Gram模型概念架构图

图4-15 Skip-Gram模型实施架构图

其中:

  • V:词汇量(语料库中的唯一词数)。
  • P:投影或词嵌入层(The Projection or the Embedding Layer)。
  • D:词向量空间的维数。
  • b:单个batch的大小。

4.2.6 定义损失函数

到目前为止,我们还没有深入讨论Word2Vec的一个非常关键的话题,那就是损失函数。一般情况下,标准softmax交叉熵损失函数是分类任务中一个非常好的损失函数。然而,这种损失函数对Word2Vec来说有时并不是很实用。在现实工作任务中,可能涉及数十亿个词,词汇量可以轻松达到100000个或者更多,这就使得softmax函数的归一化变得非常沉重,这是因为softmax的完全计算需要计算所有输出节点的交叉熵损失。所以,在保证模型性能的前提下,我们需要转向使用近似且有效的损失函数。这样一来,对于一个Word2vec模型设计而言,最大的挑战就变成如何降低softmax层的计算复杂度,这也是机器翻译(MT)(Jean等)和语言建模(Jozefowicz等)共同关注之处。语言建模中近似softmax的方法有多种,例如:

  • 多层次softmax。
  • 微分softmax。
  • CNN-softmax。
  • 基于采样(Sampling)的方法。
    • ★ 重要性采样
    • ★ 具有适应的重要性采样
    • ★ 目标采样
    • ★ 噪声对比估计
    • ★ 负采样
    • ★ 自标准化
    • ★ 低频的标准化
    • ★ 其他方法

下面只讨论一下比较流行的近似选择:负采样和多层次softmax。

1. softmax层负采样(Negative Sampling)

对于softmax损失函数的替代选择,下面我们将采用更智能的替代方案,称为sampled softmax损失。注意,与标准softmax交叉熵损失相比,这里有了很多变化。首先,需要计算的是给定目标词所在真实上下文词的ID与对应于真实上下文词ID的预测值之间的交叉熵损失。其次,我们根据一些噪声分布添加了采样的K个负样本的交叉熵损失。这就是我们说的对softmax层进行负采样。实际数据为(输入–输出),噪声为(K-many虚拟噪声输入–输出)。借助于噪声,是指使用不属于目标词所在上下文的词创建的与实际词对(输入–输出)不相符的,即使用(K-many虚拟噪声输入–输出)词对。我们还将softmax激活函数替换为sigmoid激活函数(也称为逻辑函数)。这允许我们在保持[0,1]范围内输出的同时,也可以去除损失函数对整个词汇表的依赖。在较高的层面上,我们将损失定义如下:

SigmoidCrossEntropy是我们可以在单个输出节点上定义的损失,与其余节点无关。这使它成为我们分析问题的理想选择,因为我们的词汇量会变得非常大。我们不会深入研究这种损失的细节,也不需要了解这是如何实现的,因为它们可以作为TensorFlow中的内置函数使用,但理解损失中涉及的参数(例如K)很重要。sampled softmax损失通过考虑两种类型的实体来计算损失:

  • 由预测向量(上下文窗口中的词)中的真实上下文词ID给出的索引。
  • 词ID表示的K个索引,被认为是噪声(上下文窗口之外的词)。

负采样是噪声对比估计(Noise-Contrastive Estimation,NCE)方法的近似,根据NCE可知,一个好的模型应该通过逻辑回归来区分真实数据和噪声,实际上负采样既很好地保留了模型的性能又很好地做到有效损失的近似。

结合上面的例子来说明这一点,如图4-16所示。

图4-16 softmax层负采样示例

2. 多层次softmax(Hierarchical softmax)

多层次softmax(H-softmax)是Morin和Bengio受到二叉树启发而得出的方法。从根本上来说,H-softmax是用词语作为叶子的多层结构来替代原softmax的一层,如图4-17所示。

图4-17 多层次softmax图示

这样一来,对于单个词出现概率的计算就可以被分解为一连串的概率计算,我们也就无须再对所有词进行高成本的标准化计算处理。用H-softmax来替代单一的softmax层可以大大提升预测词的速度,有研究表明,至少带来50倍的提升,因此特别适用于要求低延迟的工作任务,比如谷歌的新通信软件Allo的实时沟通功能便是如此。

我们如果把常规的softmax层想象成只有一层的树,每个V中的词均是一个叶子节点,那么在计算一个词softmax层的概率时,就需要标准化所有|V|个叶子的概率,显然计算成本非常高。如果把softmax层当成每个词都是叶子的一棵二叉树,则只需要从叶子节点开始沿着树的路径行走,就可以抵达指定的词,而无须考虑其他词,显然这种方法更佳。

多层次softmax比负采样略微复杂,但与负采样的目标相同。与负采样的不同之处是多层次softmax仅使用实际数据而不需要噪声。我们通过一个例子来做进一步解读。例如,有下面一句话:

    I like NLP. Deep learning is amazing.

上面这个句子的词汇如下:

    I, like, NLP, Deep, learning, is, amazing

使用这个词汇表,可以构建一棵二叉树,其中词汇表中的所有词都以叶子节点的形式出现。我们还将添加一个特殊的标记PAD,以确保所有树叶都有两个成员,如图4-18所示。

图4-18 所有词以叶子节点出现

接着,最后一个隐藏层将完全连接到层次结构中的所有节点。这里与经典的softmax层相比,该模型具有相似的总权重值,但是,对于给定的计算,它仅使用其中一部分。

如果我们要计算P(NLP | like)的概率,只需要一个权重值子集来计算概率即可,其中like是输入词,如图4-19所示。

图4-19 利用权重值子集来计算概率

具体来说,计算概率如下:

    ( NLP | like ) = P ( left at 1| like ) × P ( right at 2 | like ) × P ( left
at 5 | like )

由于知道了如何计算P(),我们便可以使用原始损失函数。注意,该方法仅使用连接到路径中节点的权重值进行计算,从而使得计算效率很高。

尽管多层次softmax是有效的,但是一个重要的问题仍然值得注意,那就是如何确定树的分解。更准确地说,哪个词会跟随哪个分支。下面给出两个解决方案。

(1)随机初始化层次结构:事实上,此方法会使模型的一些性能下降,因为无法保证随机分配在词之间的分支是最佳的。

(2)使用WordNet确定层次结构:WordNet可用于确定树中词的合适顺序。该方法明显表现出比随机初始化更好的性能。

4.2.7 利用TensorFlow实现Skip-Gram模型

接下来我们将使用TensorFlow来实现Skip-Gram算法。在这里,我们将仅仅讨论涉及定义TensorFlow的操作以便进行学习词向量的部分。完整代码可在ch4文件夹下的4_skip-gram_CBOW(improved).ipynb中找到。

这里下载的数据集包含多个维基百科文章,总计大约61MB。数据集来源见链接http://www.evanjones.ca/software/wikipedia2text.html。

首先定义模型的超参数。你可以自由更改这些超参数的值以查看它们如何影响最终的性能(例如,batch_size = 16或batch_size = 256)。具体如下:

    batch_size = 128
    embedding_size = 128 # 词向量的维数
    window_size = 4 #左右两边各考虑多少个词
    valid_size = 16 #用于评估相似性的随机词集
    # 仅在分布的头部选择开发样本
    valid_examples = np.array(random.sample(range(valid_window), valid_size))
    valid_examples = np.append(valid_examples,random.sample(range(1000,
1000+valid_window), valid_size),axis=0)
    num_sampled = 32 #要抽样的负样例数量

接下来,为训练输入数据集、标签和有效输入定义TensorFlow占位符:

    train_dataset = tf.placeholder(tf.int32, shape=[batch_size])
    train_labels = tf.placeholder(tf.int32, shape=[batch_size, 1])
    valid_dataset = tf.constant(valid_examples, dtype=tf.int32)

然后,为词嵌入层和softmax层的权重值及偏差定义TensorFlow变量:

    embeddings = tf.Variable(tf.random_uniform([vocabulary_size, embedding_size],
-1.0, 1.0))
    softmax_weights = tf.Variable(tf.truncated_normal([vocabulary_size,
embedding_size], stddev=0.5 / math.sqrt(embedding_size)))
    softmax_biases = tf.Variable(tf.random_uniform([vocabulary_size],0.0,0.01))

接下来,我们将定义一个词向量查找操作,该操作收集指定训练数据集对应的词向量:

    embed = tf.nn.embedding_lookup(embeddings, train_dataset)

之后,我们将使用负采样来定义softmax损失:

    loss = tf.reduce_mean(tf.nn.sampled_softmax_loss(weights=softmax_weights,
    biases=softmax_biases,inputs=embed, labels=train_labels,
num_sampled=num_sampled, num_classes=vocabulary_size))

在这里,我们定义一个优化器来优化(最小化)前面定义的损失函数。我们也可以尝试使用https:// tensorflow.google.cn /api_guides/python/train中列出的其他优化器进行试验:

    optimizer = tf.train.AdagradOptimizer(1.0).minimize(loss)

计算验证输入词示例和所有词向量之间的相似性。使用余弦距离:

    norm = tf.sqrt(tf.reduce_sum(tf.square(embeddings), 1, keepdims=True))
    normalized_embeddings = embeddings / norm
    valid_embeddings = tf.nn.embedding_lookup(normalized_embeddings,
valid_dataset)
    similarity = tf.matmul(valid_embeddings,tf.transpose(normalized_embeddings))

在定义了所有TensorFlow变量和操作后,现在可以继续执行一些操作,这里会简要给出这些操作的基本过程,具体的完整过程请参见对应的代码文件。

(1)使用tf.global_variables_initializer()初始化TensorFlow变量.run()。

(2)对于每个步骤(预定义的总步骤数),请执行以下操作:

使用数据生成器生成一批数据(batch_data - inputs,batch_labels -outputs)。

创建一个名为feed_dict的字典,将训练输入/输出占位符映射到数据生成器生成的数据:

    feed_dict = {train_dataset:atch_data,train_labels:batch_labels}

执行优化步骤并获取损失值,如下所示:

    _,l = session.run([optimizer,loss],feed_dict = feed_dict)

最终,Skip-Gram模型借助t-SNE技术对于相关数据进行可视化后的效果如图4-20所示。

图4-20 Skip-Gram模型可视化效果图示

将图4-20中的部分结果放大,如图4-21所示。

图4-21 Skip-Gram模型可视化部分放大效果图示

从放大的结果来看,这样的分类结果还是符合预期的,其余部分的分类结果,读者可以自行查验。