这篇文章记录词向量的发展历程,包括tf-idf、word2vec、GloVe、ELMo、OpenAI GPT以及Bert,只记录个人认为比较核心的内容,以及一些值得思考的边角细节。
tf-idf是一种比较传统的文本表示方法,它首先为每个词计算出一个值,再组成向量来表示当前文档。它的大小等于词表数。首先tf是词频,也就是当前词在文档中出现的次数,通常会除以文档总词数来做归一化。idf的计算方法是log(语料库中总文档数 / 包含当前词的文档数),可见分子是固定值,idf将随着包含当前词的文档数的增加而减小,也就是说常见词的idf值会相对较小,而当前文档比较有代表性的词发挥更大的作用。tf-idf的缺点是它是词袋模型,无法考虑词的位置信息,上下文信息以及一些分布特征。
实际上tf-idf就是one-hot的一种优化,还是存在维度灾难以及语义鸿沟的问题。因此后来的工作着重于构建分布式低维稠密词向量。word2vec就是它们的开山之作。我们知道NNLM(语言模型)是一种自监督训练的模型,用上文来预测下一个词的概率,那么词向量就可以作为它的副产物学习到这种基于序列共现的语境信息。word2vec基于这种思想提出了更专注于词向量学习的模型(比如舍弃隐藏层),用滑动窗口来指定固定大小的上下文,试图用当前词来预测上下文(skip-gram)或用上下文来预测当前词(CBOW)。具体细节可以参考这篇论文。
word2vec的两种加速训练策略
分层softmax
用哈夫曼树来计算词的概率,每个词对应一个叶节点。非叶节点也各自对应一个向量,词的概率可由它到根节点的唯一路径来计算。
哈夫曼树的构造方法:将词表中的每个词看作只有一个结点的树,用词频来表示它们的权重。选择根节点权重最小的两棵树合并,合并后的父节点权重等于两个子结点之和。下面是一个例子:
为了保证概率相加等于1,在路径上采用sigmoid来计算向左或向右(n表示结点,v表示结点向量):
p ( n , l e f t ) = σ ( v T h ) p(n, left) = \sigma (v^T\mathbf{h}) p(n,left)=σ(vTh) p ( n , r i g h t ) = 1 − σ ( v T h ) = σ ( − v T h ) p(n, right) = 1 - \sigma (v^T\mathbf{h}) = \sigma (-v^T\mathbf{h}) p(n,right)=1−σ(vTh)=σ(−vTh)
那么某个词 w o w_o wo 出现的概率就是:
[[·]]是个1/-1函数,左子结点取1,右子结点取-1。 再算cross entropy即可:
这样就代替了softmax,复杂度从O(N)变成O(log N)。
负采样
采样概率:在词频上取0.75次幂,减小词频差异带来的采样影响,即
w e i g h t ( w ) = c o u n t ( w ) 0.75 ∑ u c o u n t ( u ) 0.75 weight(w) = \frac{count(w)^{0.75}}{\sum_u count(u)^{0.75}} weight(w)=∑ucount(u)0.75count(w)0.75那么损失函数为:
E = − log σ ( v w o ′ T h ) − ∑ w N ∈ N E G log σ ( − v w N ′ T h ) E = -\log \sigma(v'^T_{w_o} \mathbf{h}) - \sum_{w_N \in NEG} \log \sigma(-v'^T_{w_N} \mathbf{h}) E=−logσ(vwo′Th)−wN∈NEG∑logσ(−vwN′Th) w o w_o wo 是目标词, w I w_I wI 是输入词。对于skip-gram, h = v w I \mathbf{h} = v_{w_I} h=vwI,对于CBOW, h = 1 C ∑ c = 1 C v w c \mathbf{h} = \frac{1}{C}\sum_{c=1}^{C} v_{w_c} h=C1∑c=1Cvwc。
word2vec只能抽取局部特征,词的上下文信息局限于滑动窗口大小。
细节推荐这篇博客。主要的几个步骤包括:
构建共现矩阵
GloVe指定特定大小的上下文窗口,通过滑动该窗口统计共现矩阵X(|V|*|V|), X i j X_{ij} Xij 表示中心词i与上下文词j的共现次数。同时还定义了衰减函数,令距离为d的两个词在计数时乘以1/d。
确定近似目标
作者发现可以用概率之比来建模共现关系。定义条件概率P:
P i j = P ( j ∣ i ) = X i j X i P_{ij} = P(j|i) = \frac{X_{ij}}{X_i} Pij=P(j∣i)=XiXij表示词j出现在i上下文的概率。而用词k出现在i的上下文与它出现在j上下文的概率之比
r a t i o i , j , k = P i k P j k ratio_{i,j,k} = \frac{P_{ik}}{P_{jk}} ratioi,j,k=PjkPik来表示i,j,k三个词之间的共现关系。当i,k和j,k相关程度近似时,该比率趋近于1;i,k相关度大于j,k相关度时该比率值较大,反之则较小。GloVe的目标就是使学习到的词向量满足这样的规律,既有自身上下文信息,又能和其它词联系起来。目标函数:
F ( w i , w j , w k ) = P i k P j k F(w_i,w_j,w_k) = \frac{P_{ik}}{P_{jk}} F(wi,wj,wk)=PjkPik要同时满足三个词的约束关系,训练复杂度会变得很高。作者通过一系列变(硬)换(凑),把上式转换成了两个词的约束目标:
F ( w i , w j , w k ) = exp ( ( w i − w j ) T w k ) = exp ( w i T w k − w j T w k ) = exp ( w i T w k ) exp ( w j T w k ) = P i k P j k \begin{aligned} F(w_i,w_j,w_k) &= \exp((w_i - w_j)^Tw_k) \\ &= \exp(w_i^Tw_k - w_j^Tw_k) \\ &= \frac{\exp (w_i^Tw_k)}{\exp (w_j^Tw_k)} = \frac{P_{ik}}{P_{jk}} \end{aligned} F(wi,wj,wk)=exp((wi−wj)Twk)=exp(wiTwk−wjTwk)=exp(wjTwk)exp(wiTwk)=PjkPik由此
P i k = exp ( w i T w k ) P_{ik} = \exp (w_i^Tw_k) Pik=exp(wiTwk)成为新的目标函数。然而这种内积计算方式具有对称性,为了避免这种错误的性质,作者继续变(硬)换(凑):
P i k = X i k X i = exp ( w i T w k ) log P i k = log ( X i k ) − log X i = w i T w k w i T w k + log X i = log ( X i k ) P_{ik} = \frac{X_{ik}}{X_i} = \exp (w_i^Tw_k) \\ \log P_{ik} = \log (X_{ik}) - \log X_i = w_i^Tw_k \\ w_i^Tw_k + \log X_i = \log (X_{ik}) Pik=XiXik=exp(wiTwk)logPik=log(Xik)−logXi=wiTwkwiTwk+logXi=log(Xik)将 log X i \log X_i logXi看作常数项,再添加一个偏置 b k b_k bk,目标函数最终形式为:
w i T w k + b i + b k = log ( X i k ) w_i^Tw_k + b_i + b_k = \log(X_{ik}) wiTwk+bi+bk=log(Xik)其中 X i k X_{ik} Xik 是共现矩阵中的值。
构造损失函数(平方损失)
J = ∑ i , k = 1 V f ( X i k ) ( w i T w k + b i + b k − log ( X i k ) ) 2 J = \sum_{i,k=1}^V f(X_{ik}) (w_i^Tw_k + b_i + b_k - \log(X_{ik}))^2 J=i,k=1∑Vf(Xik)(wiTwk+bi+bk−log(Xik))2其中 f ( X i j ) f(X_{ij}) f(Xij) 是关于共现矩阵的权重函数,
也就是说,共现次数越少,对它们的相关性约束越小。
推导过程见这篇博客或原论文。
Glove和Word2vec比较
Fasttext最早其实是一个文本分类算法,后续加了一些改进来训练词向量。概括了几点:
之前那些方法构造的都是独立于上下文的word embedding,也就是无论下游任务是什么,输入的embedding始终是固定的,这就无法解决一词多义,以及在不同语境下有不同表现的需求。所以后续的ELMo,GPT-2以及BERT都是针对于这类问题提出的,通过预训练和fine-tune两个阶段来构造context-dependent的词表示。
ELMo使用双向语言模型来进行预训练,用两个分开的双层LSTM作为encoder。biLM的loss是:
L = ∑ k = 1 N ( log p ( t k ∣ t 1 , . . . , t k − 1 ; Θ → L S T M , Θ s ) + log p ( t k ∣ t k + 1 , . . . , t N ; Θ ← L S T M , Θ s ) ) L = \sum_{k=1}^N (\log p(t_k|t_1,...,t_{k-1};\overrightarrow{\Theta}_{LSTM},\Theta_s) + \log p(t_k|t_{k+1},...,t_{N};\overleftarrow{\Theta}_{LSTM},\Theta_s)) L=k=1∑N(logp(tk∣t1,...,tk−1;ΘLSTM,Θs)+logp(tk∣tk+1,...,tN;ΘLSTM,Θs))
其中 Θ s \Theta_s Θs 是softmax层参数。作者认为第一层学到的是句法信息,第二层学到的是语义信息。这两层LSTM的隐状态以及初始的输入加权求和就得到当前词的embedding。ELMo还设置了一个参数,不同的下游任务可以取特定的值,来控制ELMo词向量起到的作用。总体来说第k个token得到的预训练embedding就是:
E L M o k t a s k = γ t a s k ∑ j = 0 L s j t a s k h k j L M \mathbf{ELMo}_k^{task} = \gamma^{task} \sum_{j=0}^{L}s_j^{task} \mathbf{h}_{kj}^{LM} ELMoktask=γtaskj=0∑LsjtaskhkjLM
在面对具体下游任务时,首先固定biLM的参数得到一个词表示,再与上下文无关的词表示(word2vec,或者charCNN获得的表示)拼接作为模型输入,在反向传播时fine-tune所有参数。
原文中提到的一些细节:
GPT和BERT与ELMo不同,ELMo使用LSTM作为编码器,而这两个用的是编码能力更强的Transformer。
GPT也是用语言模型进行大规模无监督预训练,但使用的是单向语言模型,也就是只根据上文来预测当前词。它实现的方式很直观,就是Transformer的decoder部分,只和前面的词计算self-attention来得到表示。在下游任务上,之前的ELMo相当于扩充了其它任务的embedding层,各个任务的上层结构各不相同,而GPT则不同,它要求所有下游任务都要完全与GPT的结构保持一致,只在输入输出形式上有所变化:
这是在NLP上第一次实现真正的端到端,不同的任务只需要定制不同的输入输出,无需构造内部结构。这样预训练学习到的语言学知识就能直接引入下游任务,相当于提供了先验知识。比如说人在做阅读理解时,先通读一遍全文再根据问题到文章中找回答,这些两阶段模型就类似这个过程。为了防止fine-tune时丢失预训练学到的语言知识,损失函数同时考虑下游任务loss( L 2 L_2 L2)和语言模型loss( L 1 L_1 L1):
L 3 ( C ) = L 2 ( C ) + λ L 1 ( C ) L_3(C) = L_2(C) + \lambda L_1(C) L3(C)=L2(C)+λL1(C)
个人认为GPT的最大创新:
推荐这篇博客(这位大佬的其它文章质量也超高,尤其Transformer那篇估计是好多人的入门必看)
GPT虽然效果很好,但它在预训练时使用的是transformer的decoder部分,也就是单向语言模型,在计算attention时只能看见前面的内容,这样embedding获得的上下文信息就不完整。ELMo虽然是双向语言模型,但实际上是分开执行再组合loss,这就会带来一定的损失。
与GPT不同的是,bert在预训练时除了语言模型loss以外,还增加了一个“next sentence prediction”任务,即两个句子组成sentence pair同时输入,预测第二句是否是第一个句子的下文,是一个二分类任务。
wordpiece-token
词向量,这里的wordpiece是将token拆分成子词。position emb
位置向量segment emb
句子标识,属于第一个句子则为0,第二个句子则为1[CLS]
; sent1 ; [SEP]
; sent2 ;[SEP]
Masked Language Model
所谓双向LM,就是在预测当前词时同时考虑上文和下文,也就是
p ( t k ∣ t 1 , . . . , t k − 1 , t k + 1 , . . . , t N ) p(t_k | t_1, ..., t_{k-1}, t_{k+1}, ...,t_N) p(tk∣t1,...,tk−1,tk+1,...,tN)
但LM是要逐词预测的,用这种概率计算方法会导致信息泄露,也就是当前词已经在之前的预测中作为下文而暴露了。作者由此提出了MASK策略,只选取15%的词进行预测,在输入时用[MASK]标记替代,而仍然以原词作为训练target。类似于阅读理解中的Cloze任务。当然,预测的词少了,模型收敛速度就会变慢,需要的训练step也要相应增加。
mask解决了信息泄露问题,但实际输入(也就是fine-tune阶段)不会包含这种标记,导致两阶段不一致,对训练效果产生影响。作者的解决方案是在这随机选取的15%词当中,80%的概率替换为[MASK],10%的概率替换成其它词(负采样) ,10%的概率保留原词。这样有一个好处是,模型不知道当前要预测的词是否被篡改了,迫使其更关注上下文,学习到上下文相关的表示,这正是我们的目的。
作者还在附录里给出了一个扩展实验,对比不同的预训练mask策略对后续结果的影响:
可以看到至少在这两个任务中,结果对不同的mask法是鲁棒的,差别不大。但从最后两条可以看出,直接去掉[MASK],80%或100%取负样本的效果相比之下差了很多。按理说使用负样本相当于构建去噪自编码器,到底比MASK差在哪?思考了一下,原因很可能是负采样词作为其它词的上下文输入,使得这些词学到的embedding融合了错误的信息,对训练造成影响;而[MASK]本身并没有任何含义,它从未作为target出现过,也就没有特定的出现语境,因此其embedding没有实际意义,对其它词的影响也就相对较小。
Next Sentence Prediction
0/1分类任务。从语料中选取两个片段AB(注意这里是两个“span”,而不是实际意义上的“句子”,因为希望输入尽可能长)作为一条输入,50%的概率AB连续(1),50%不连续(0)。输出在[CLS]处取FFNN+Softmax做二分类预测。输入的最大长度是512,超过则直接截断。
预训练数据及规模
BooksCorpus (800M words) 加 Wikipedia (2,500M words)
参数设置
训练loss
masked LM与NSP的log likelihood之和
fine-tuning的任务主要分成基于句子的和基于token的。基于句子的一般取[CLS]的embedding输出预测,基于token的则直接取对应位置的输出进行预测。
一般需根据特定的任务重新设置batch_size, learning rate, epochs超参数,其余与预训练保持一致即可。
fine-tuning的任务主要分成基于句子的和基于token的。基于句子的一般取[CLS]的embedding输出预测,基于token的则直接取对应位置的输出进行预测。
一般需根据特定的任务重新设置batch_size, learning rate, epochs超参数,其余与预训练保持一致即可。
预训练好的Bert除了用于fine-tuning以外,还可以像ELMo一样作为特征抽取器,也就是直接用学习到的word embeddings当做其它模型的输入。目前看来最好的选择是最后四层向量拼接。
Bert与GPT的区别:
Bert最大的创新: