切换视频源:

Skip-Gram

作者: 莫烦 编辑: 莫烦 2020-07-22

学习资料:

怎么了

Skip-Gram 要解决的问题和 CBOW 一样,就是为了让计算机理解词语, 而这种理解,并不像我们人类理解词语那样。计算机能够理解的都只是一些数字,所以我们挑选了数字形式的理解,称为Vector向量。 你可以从我制作的这个短片中了解到计算机是如何将文字转换成向量理解的。

在NLP中,词语的Vector向量,我们称之为词向量,用词向量我们也能做很多有趣的事情, 上节内容我们也阐述了像句子理解,词语加减法这样的有趣例子。这节内容和上节内容有很强的相关性, 如果还没看过上节内容的同学,我也强烈建议花个五分钟看看上节内容,你理解起来这节内容也就相当的容易了。

什么是Skip-Gram

之前我们看到了 CBOW 有着这样的结构,使用上下文来预测上下文之间的结果。如下图:

cbow

而 Skip-Gram 则是把这个过程反过来,使用文中的某个词,然后预测这个词周边的词。

skip gram

Skip-Gram 相比 CBOW 最大的不同,就是剔除掉了中间的那个 SUM 求和的过程,我在这里提到过, 我觉得将词向量求和的这个过程不太符合直观的逻辑,因为我也不知道这加出来的到底代表着是一个句向量还是一个另词向量,求和是一种粗暴的类型转换。 而Skip-Gram没有这个过程,最终我们加工的始终都是输入端单个词向量,这样的设计我比较能够接受。如果你们也有更好的理解,欢迎在下方留言。

秀代码

和上节内容一样,为了做一个有区分力的词向量,我做了一些假数据,想让计算机学会这些假词向量的正确向量空间。 如果你习惯直接看所有源码,那么源码在这里

我将训练的句子人工分为两派(数字派,字母派),虽然都是文本,但是期望模型能自动区分出在空间上,数字和字母是有差别的。因为数字总是和数字一同出现, 而字母总是和字母一同出现。并且我还在字母中安排了一个数字卧底,这个卧底的任务就是把字母那边的情报向数字通风报信。 所以期望的样子就是数字 9 不但靠近数字,而且也靠近字母。

corpus = [
    # numbers
    "5 2 4 8 6 2 3 6 4",
    "4 8 5 6 9 5 5 6",
    "1 1 5 2 3 3 8",
    "3 6 9 6 8 7 4 6 3",
    "8 9 9 6 1 4 3 4",
    "1 0 2 0 2 1 3 3 3 3 3",
    "9 3 3 0 1 4 7 8",
    "9 9 8 5 6 7 1 2 3 0 1 0",

    # alphabets, expecting that 9 is close to letters
    "a t g q e h 9 u f",
    "e q y u o i p s",
    "q o 9 p l k j o k k o p",
    "h g y i u t t a e q",
    "i k d q r e 9 e a d",
    "o p d g 9 s a f g a",
    "i u y g h k l a s w",
    "o l u y a o g f s",
    "o p i u y g d a s j d l",
    "u k i l o 9 l j s",
    "y g i s h k j l f r f",
    "i o h n 9 9 d 9 f a 9",
]

先来看看最终的向量空间效果。和CBOW类似,我们也能清楚的辨别出字母和数字有着不同的向量空间,而 9 这个内奸也能比较明显的被安排到了又接近字母又接近数字的空间中。

skip gram result

Skip-Gram 在词语的 embedding 上和 CBOW 没有区别,所以下面这一步是一模一样的。

from tensorflow import keras
import tensorflow as tf

class SkipGram(keras.Model):
    def __init__(self, v_dim, emb_dim):
        super().__init__()
        self.embeddings = keras.layers.Embedding(
            input_dim=v_dim, output_dim=emb_dim,  # [n_vocab, emb_dim]
            embeddings_initializer=keras.initializers.RandomNormal(0., 0.1),
        )
        ...

而在前向的过程中,就是体现他们不同之处的地方了,Skip-Gram 的前向,反而比CBOW的前向更简单了,只有一个取embedding的过程。

class SkipGram(keras.Model):
    ...
    def call(self, x, training=None, mask=None):
        # x.shape = [n, ]
        o = self.embeddings(x)      # [n, emb_dim]
        return o

loss 的计算和反向传递在意义上和CBOW不太一样,不过在代码实现上却是一模一样的。看过上节内容的朋友应该很容易看懂。需要提到的一点是, 在计算loss的时候,为了避免词汇量大,带来的softmax计算复杂度高的问题,这个地方像CBOW一样,使用了NCE技术,在计算loss和反向传播的时候只考虑部分的负样本, 节约计算量。

class SkipGram(keras.Model):
    def __init__(self, v_dim, emb_dim):
        ...
        # noise-contrastive estimation
        self.nce_w = self.add_weight(
            name="nce_w", shape=[v_dim, emb_dim],
            initializer=keras.initializers.TruncatedNormal(0., 0.1))  # [n_vocab, emb_dim]
        self.nce_b = self.add_weight(
            name="nce_b", shape=(v_dim,),
            initializer=keras.initializers.Constant(0.1))  # [n_vocab, ]

        self.opt = keras.optimizers.Adam(0.01)

    # negative sampling: take one positive label and num_sampled negative labels to compute the loss
    # in order to reduce the computation of full softmax
    def loss(self, x, y, training=None):
        embedded = self.call(x, training)
        return tf.reduce_mean(
            tf.nn.nce_loss(
                weights=self.nce_w, biases=self.nce_b, labels=tf.expand_dims(y, axis=1),
                inputs=embedded, num_sampled=5, num_classes=self.v_dim))

    def step(self, x, y):
        with tf.GradientTape() as tape:
            loss = self.loss(x, y, True)
            grads = tape.gradient(loss, self.trainable_variables)
        self.opt.apply_gradients(zip(grads, self.trainable_variables))
        return loss.numpy()

等一下!!不是说好了要用这个embedding预测前后多个词吗?怎么会一模一样? 原因是这样,为了简化训练过程,我们在数据处理的时候process_w2v_data()做了一些小处理。

在CBOW中,我们的X数据每行有前后N个词语,Y中只有1个词; 而在Skip-Gram中,X数据只有1个词,Y也只有1个词。

为什么Skip-Gram的Y也只有一个词,这和上面的图不一致呀?真相只有一个,虽然Y只有一个词,不过我们可以使用同一个X,对前后N个词都预测一遍,逻辑是下面这样:

# 原本应该是这样:
# 输入:莫
# 输出:[我,爱] + [烦,Python]

# 对同一批训练数据,你也可以这样
# 输入:莫 -> 输出:我
# 输入:莫 -> 输出:爱
# 输入:莫 -> 输出:烦
# 输入:莫 -> 输出:Python

经过这样一个处理,神经网络的训练过程就简单了。代码量也少了,容易实现了,何乐而不为。

那么训练的过程也和CBOW一样的,只是process_w2v_data()这里的参数需要变化一下,我们要拿的是给Skip-Gram定制的输入输出。

from utils import process_w2v_data

def train(model, data):
    for t in range(2500):
        bx, by = data.sample(8)
        loss = model.step(bx, by)
        if t % 200 == 0:
            print("step: {} | loss: {}".format(t, loss))


if __name__ == "__main__":
    d = process_w2v_data(corpus, skip_window=2, method="skip_gram")
    m = SkipGram(d.num_word, 2)
    train(m, d)

值得注意的是,在数据处理方面,我没在代码中特别强调,但是可以看出,process_w2v_data() 这个功能需要确定我们的skip_window这个参数, 这个参数的作用就是,在我们要预测的词周围,我们要选取多少个词作为他的输入。如果skip_window=1则意味着我们选取这个词前后各1个词作为网络的输入, 如果skip_window=2则意味着我们选取这个词前后各2个词,以此类推。具体代码可以看到我写在 utils.py

能不能更好

我们已经能训练出词向量了,不过在生活中,我们肯定会遇到一词多义这种情况。用Skip-Gram或者CBOW训练出来的词向量能否解决这个问题? 答案是不行的,因为它们会针对每一个词生成唯一的词向量,也就是这个词有且只有一种解释(向量表达)。举个例子,在w2v看来,下面这两句话的 2是一个意思。

  • 我住在2号房。
  • 高铁还有2站到。

想想你在口语表达中,是怎么用不同的口语形式说明这两句中的 2 的不同之处?你会不会这样说?

  • 我住在号房。
  • 高铁还有站到。

同样是 2,但是说房间号的时候我理解它只是一个编号,因为比较房号的大小没有实际的意义,但是当数字有距离感的时候,比如两站,这个 2 的意义就不一样了。 还有很多例子,比如我是阳光男孩今天阳光明媚。如果用单纯的词向量是没有办法表达阳光的不同含义的。

有什么办法让模型表达出词语的不同含义呢?当然我们还是站在向量的角度,只是这个词向量的表示如果能考虑到句子上下文的信息, 那么这个词向量就能表达词语在不同句子中不同的含义了。在后面的ELMo模型中,我们会探讨这一种做法。

总结

Skip-Gram是一个提取词向量的过程,有了这些词向量,我们可以用在对句子的理解、对词语的匹配等后续NLP流程中。相比CBOW而言,我更喜欢Skip-Gram这种处理方式, 因为少了词向量相加的中间处理过程,我理解起来更加顺畅。

降低知识传递的门槛

莫烦很常从互联网上学习知识,开源分享的人是我学习的榜样。 他们的行为也改变了我对教育的态度: 降低知识传递的门槛免费 奉献我的所学正是受这种态度的影响。 通过 【赞助莫烦】 能让我感到认同,我也更有理由坚持下去。

想当算法工程师拿高薪?转行AI无门道?莫烦也想祝你一臂之力,市面上机构繁杂, 经过莫烦的筛选,七月在线脱颖而出, 莫烦和他们合作,独家提供大额 【培训优惠券】, 让你更有机会接触丰富的教学资源、培训辅导体验, 祝你找/换工作/学习顺利~