Word Embedding总结

这篇博客主要记录自己对word embedding的思考和总结,主要是对word2vec、GloVe和fastText的一些思考。

word2vec

  首先强烈推荐下这篇博文,写得非常好,我从中学到了很多,然后我再来讲下自己对word2vec的理解。word2vec有2种模型结构,一种是基于context预测word,称为CBOW;一种是给定word预测周围的words。两种结构如下图所示,这里就不啰嗦了:

Word Embedding总结_第1张图片
word2vec的2种模型结构.png
  这里我主要讲一下实现代码上的性能优化。一般来说模型最后的输出向量维度都特别大,所以对应的softmax操作也是非常耗时的。为了减少softmax层的时间消耗,word2vec作者提出了2种解决方法——分层softmax和负采样。

  1. 使用分层softmax时,此时就需要对projection和output之间的结构就要发生变化,这里的全连接层(带softmax)结构就要变成一个霍夫曼树结构了,如下图:
    Word Embedding总结_第2张图片
    带有分层softmax的CBOW结构.png
    单词(叶节点)中保存了从根节点到该叶节点的路径,而中间节点中则保存了一个embedding长度的参数向量(其实可以理解为将全连接层中的2维参数矩阵拆分成多个向量保存在不同中间节点中),每经过一个中间节点就将与节点中参数向量做点积并通过sigmoid函数(),再将结果与label求得误差并更新中间节点中的参数和embedding表中的参数。当然,代码中是直接对中间节点更新参数,并保存其传到embedding表的更新量,等到路径遍历完后再将所有中间节点的更新量统一并对embedding表进行更新。本质上来说,就是将一个分类任务变成了个二分类任务。更详细的讲解可以看这篇博文。
    接下来讲一下源码中是怎么实现霍夫曼树的,关键代码如下:
for (a = 0; a < vocab_size - 1; a++){
    if (pos1 >= 0) {
        if (count[pos1] < count[pos2]) {
            min1i = pos1;
            pos1--;
        } else {
        min1i = pos2;
        pos2++;
        }
    } else {
        min1i = pos2;
        pos2++;
    }
    if (pos1 >= 0) {
        if (count[pos1] < count[pos2]) {
            min2i = pos1;
            pos1--;
        } else {
            min2i = pos2;
            pos2++;
        }
    } else {
        min2i = pos2;
        pos2++;
    }
    count[vocab_size + a] = count[min1i] + count[min2i];
    parent_node[min1i] = vocab_size + a;
    parent_node[min2i] = vocab_size + a;
    binary[min2i] = 1;
}

这里就是构建霍夫曼树的关键代码。先解释下其中各个变量的作用:count是一个长度为(2*vocab_size+1)的数组,前半部分是排好序(降序)的词频,后半部分均填入一个很大的数(1e15);pos1和pos2是两个指针,分别初始化指向count前半部分的尾和后半部分的头;min1i和min2i则是保存for循环中每个时刻的最小的两个值对应的index。其实我们可以将count前半部分看做霍夫曼树的叶子节点,count后半部分看做霍夫曼树的中间节点。每次比较pos1和pos2的大小并将最小的两个值保存在min1i和min2i中,然后将两个最小值求和存在count后半部分对应位置处。由于count前半部分已经按照降序排好序了,所以每次寻找最小叶子节点只需要向左移动就可以了,而count后半部分值是利用当前阶段最小的两个值之和更新的,所以也可以保证count后半部分的已更新部分也是排好序的(升序)。这样我们在构建霍夫曼树过程中寻找最小两个值的耗时就是的时间复杂度,霍夫曼树的构建时间大大减少。最后我们再将每个节点对应的父节点index保存下来就行了,后面根据具体一个叶子节点就可以很快找到其到根节点的路径了。

  1. 使用negtive sampling时,projection和output之间的结构基本不变,还是有一个全连接层,但是此时就将后面的softmax层去掉了。直接对输出向量进行softmax操作太耗费时间了,negtive sampling的做法就是保留正类别,随机选择个负类别,然后对每种类别进行二分类,并对全连接层和embedding表进行参数更新。也就是说negtive sampling其实就是将一个分类任务变成了个二分类任务。当然这里还有个关键技术就是如何对负类别进行采样,每个负类别被抽中的概率与其对应的word出现频次正相关,概率公式为。更详细的讲解可以看博文或者视频。
    下面看下源码中是怎么实现negtive sampling中的带权采样问题的,negtive sampling过程主要是下面这几行代码:
next_random = next_random * (unsigned long long)25214903917 + 11;
target = table[(next_random >> 16) % table_size];
if (target == 0) target = next_random % (vocab_size - 1) + 1;
if (target == word) continue;
label = 0;

显然,实现带权采样的关键就是代码中的table变量,我们来看下table是怎么实现的,代码如下:

void InitUnigramTable() {
    int a, i;
    double train_words_pow = 0;
    double d1, power = 0.75;
    table = (int *)malloc(table_size * sizeof(int));
    for (a = 0; a < vocab_size; a++) train_words_pow += pow(vocab[a].cn, power);
    i = 0;
    d1 = pow(vocab[i].cn, power) / train_words_pow;
    for (a = 0; a < table_size; a++){
        table[a] = i;
        if (a / (double)table_size > d1) {
            i++;
            d1 += pow(vocab[i].cn, power) / train_words_pow;
        }
        if (i >= vocab_size) i = vocab_size - 1;
    }
}

这里其实就是利用了轮盘赌算法的思想。首先创建一个长度为table_size(越大则精度越高,源码中取的是1e8)的table,然后根据不同单词的概率将table分成vocab_size个不等长部分,之后利用next_random在table中的区域来选择对应负类别。

  还要补充一点的是word2vec中还提到了对高频词的采样。在制作训练集的时候为了防止and,to,of之类的高频词对embedding效果的影响,word2vec提出了对高频词进行采样,出现频率越高的单词越有可能不被选入训练集中,这个概率是。

Glove

  GloVe论文的abstract已经对GloVe做了很好的总结——综合了global matrix factorization和local context window两种方法的优点。Global matrix是对整个训练语料库进行了信息的统计,记录的是语料库中单词之间的统计信息,这个统计信息矩阵是现有的无监督词向量学习算法的关键;local context window则是记录了语料的局部信息。GloVe模型的训练在基于局部信息的基础上加入了全局信息,从信息论的角度来看模型也应该会有一定的提升。我们可以这样理解:模型学习可以看做是赋予模型一个减少熵的能力。一个样本经过模型的处理后就有了label,这意味着什么——样本熵的减少,也就是信息的注入,所以模型的学习就是将训练集中的信息转移到模型中。GloVe模型的训练在局部信息的基础上有加入了全局信息,在理想的情况下模型获得的信息也就更多了,训练出来的词向量也就更加准确些。当然这只是我个人的感性看法,怎么从数学角度证明还不会。。。
  接着直接来看GloVe的损失函数(损失函数推导过程我也是一知半解,这里就不写了,感兴趣的请看论文):,其中。就是语料库中单词之间的统计信息矩阵了,而可以看做是从embedding表中提取出的embedding向量,模型需要训练的就是embedding表,最后我们需要的也是这些embedding表。可以认为是样本的权重,是非递减的,表明单词出现次数越多则相对越重要,这里用相对是因为当词频到达一定数量后权重就是常量了。
我们来看看GloVe的源码。代码中初始化一个embedding表用来保存和,同时还初始化一个和embedding表相同维度的矩阵用来保存累积梯度,因为GloVe的优化算法不是SGD,而是AdaGrad。而且源码中用异步梯度下降法替代了同步梯度下降法,代码中开启了多个线程同时进行前向运算,并各自对embedding表和累积梯度矩阵进行更新,而不是等所有线程的前向运算结束后再统一所有梯度对参数进行更新。核心代码就是glova_thread函数。其中,损失函数的计算代码是:

diff = 0;
for (b = 0; b < vector_size; b++) diff += W[b + l1] * W[b + l2];
diff += W[vector_size + l1] + W[vector_size + l2] - log(cr.val);
fdiff += (cr.val > x_max) ? diff : pow(cr.val / x_max, alpha) * diff;
cost[id] += 0.5 * fdiff * diff; 

接下来就将只需要通过反向传播对参数进行更新就行了,当然在这个过程中也要不断更新累积梯度矩阵。

fastText

  博客写到这我都已经有点迷糊了,不知道是否应该将fastText和word2vec,GloVe进行对比。三者确实都有训练词向量的功能。但word2vec更多的是为了预测单词,词向量只是副产品,GloVe倒是为了找到一个合理的来使得不同的单词之间的共现概率也不一样。而fastText也不是为了训练词向量,它最主要的任务是对文本进行分类。fastText的模型结构与word2vec中的CBOW很相似,但是两者的输入不同,而且任务也不同。fastText的亮点主要有:

  1. 像word2vec一样,fastText也将最后的softmax层改成了分层softmax,这样在训练的时候就可以大大减少计算量了,而且模型在预测阶段也减少了很多计算量。论文中就提到了在实验中发现测试时间复杂度降为。具体实现的代码暂时没看太懂,这里先挖个坑,现在还是个C++新手,看fastText源码太吃力了。
  2. fastText中对单词的embedding表示与之前的不一样。word2vec和GloVe都是单词级别的embedding,但是fastText还加入了字符级别的embedding。比如单词where,word2vec和GloVe都直接用一个embedding向量来表示,但是fastText中会将where拆成whe、her、ere,然后用来表示where,也就是会从embedding表中选出4个embedding向量求和作为单词where的新的embedding表示向量。这样做的好处有两点,一是低频词对应的词向量的训练效果更好,因为低频词可能会跟高频词共享部分字词向量,二是对于训练词库之外的单词,fastText仍然可以构建它们的词向量,通过叠加字词向量依然可以让新单词得到较好的embedding表示向量。当然,按照字符级别对单词进行embedding意味着模型的embedding表将会变得十分巨大,fastText的相应措施是对子词进行hash处理,相同hash值的子词会共享同一个embedding向量。

2019.2.14新增:

  1. 查阅和fastText中子词hash相关的论文
    fastText中子词hash相关的论文是Feature Hashing for Large Scale Multitask Learning。fastText中由于将单词进行了拆分,加入了字符级别的embedding,其对应的embedding表也是巨大的,这就为计算和存储带来很大的挑战。fastText的做法是将子词对应的原始特征向量进行hash,将高维稀疏离散特征映射到低维离散特征空间中(这和SVM中的kernel的作用刚好相反,SVM中为了样本的线性可分性而利用kernel将特征映射到高维空间中)。具体hash算法公式如下:其中和都是hash函数,将输入映射到{1,...,m}之间,将输入映射到{+1,-1}之间。直白点讲,就是将N维的原始离散特征(比如one-hot特征)hash成M维的新特征(M<
    Word Embedding总结_第3张图片
    feature hashing.png
当然这是很理想的情况,hash是很容易导致碰撞情况的,从而导致最后算法的性能的损失。所以在实践中还需要考虑:选好hash函数(hash表的规模要选好,不能太小了);可以维护多张hash表,最后再综合起来。Feature hashing的优势在于实现简单,所需额外计算量小;如果有新的特征(训练集中未出现)加进来也没有关系(反正都可以hash到低维空间中)等。

2019.2.16新增:

  1. 查阅3种模型都用到的异步随机梯度下降的论文
    论文名字是Hogwild!: A Lock-Free Approach to Parallelizing Stochastic Gradient Descent。知乎上这个关于异步随机梯度下降的回答还是很不错的。异步随机梯度下降的关键在于参数的更新是不加锁的。首先我们看下同步随机梯度下降和异步随机梯度下降的区别,如下图:
    Word Embedding总结_第4张图片
    TensorFlow中异步更新和同步更新.png
    同步更新就是等所有线程的前向传播结束后将每个线程的loss求和再对模型参数进行更新,而异步更新就是不用等所有线程的前向传播过程都结束再更新参数,而是每个线程单独对模型参数进行更新,只要自己的loss计算完了就可以利用loss梯度更新参数。但是异步会有一个问题,那就是两个线程可能同时对参数进行更新,这时可以对参数进行加锁,当一个线程对参数进行更新时另外的线程是不能对参数进行更新的,而Hogwild!算法提出可以不用加锁,多个线程可以同时对参数进行更新,因为在随机选择训练样本的情况下,不同线程更新的模型参数大多也是不一样的,大部分情况参数的更新是不会发生冲突的,即使有冲突了影响也不大,而去掉加锁后减少的排队时间却是很可观的。
TODO:
  1. 查阅BPE算法,并与分层softmax、negtive sampling做对比
  2. 加入对debias的思考(看吴恩达视频发现的一个名词)

你可能感兴趣的:(Word Embedding总结)