tf-idf是一种比较传统的文本表示方法,它首先为每个词计算出一个值,再组成向量来表示当前文档。它的大小等于词表数。首先tf是词频,也就是当前词在文档中出现的次数,通常会除以文档总词数来做归一化。idf的计算方法是log(语料库中总文档数 / 包含当前词的文档数),可见分子是固定值,idf将随着包含当前词的文档数的增加而减小,也就是说常见词的idf值会相对较小,而当前文档比较有代表性的词发挥更大的作用。tf-idf的缺点是它是词袋模型,无法考虑词的位置信息,上下文信息以及一些分布特征。
实际上tf-idf就是one-hot的一种优化,还是存在维度灾难以及语义鸿沟的问题。因此后来的工作着重于构建分布式低维稠密词向量。word2vec就是它们的开山之作。我们知道NNLM(语言模型)是一种自监督训练的模型,用上文来预测下一个词的概率,那么词向量就可以作为它的副产物学习到这种基于序列共现的语境信息。word2vec基于这种思想提出了更专注于词向量学习的模型(比如舍弃隐藏层),用滑动窗口来指定固定大小的上下文,试图用当前词来预测上下文(skip-gram)或用上下文来预测当前词(CBOW)。具体细节可以参考这篇论文。
word2vec的两种加速训练策略
用哈夫曼树来计算词的概率,每个词对应一个叶节点。非叶节点也各自对应一个向量,词的概率可由它到根节点的唯一路径来计算。
哈夫曼树的构造方法:将词表中的每个词看作只有一个结点的树,用词频来表示它们的权重。选择根节点权重最小的两棵树合并,合并后的父节点权重等于两个子结点之和。下面是一个例子:
为了保证概率相加等于1,在路径上采用sigmoid来计算向左或向右(n表示结点,v表示结点向量):
p ( n , left ) = σ ( v T h ) p ( n , right ) = 1 − σ ( v T h ) = σ ( − v T h ) \begin{array}{c} p(n, \text {left})=\sigma\left(v^{T} \mathbf{h}\right) \\ p(n, \text {right})=1-\sigma\left(v^{T} \mathbf{h}\right)=\sigma\left(-v^{T} \mathbf{h}\right) \end{array} p(n,left)=σ(vTh)p(n,right)=1−σ(vTh)=σ(−vTh)
那么某个词 w o w_{o} wo 出现的概率就是:
p ( w = w O ) = ∏ j = 1 L ( w ) − 1 σ ( [ [ n ( w , j + 1 ) = ch ( n ( w , j ) ) ] ] ⋅ v n ( w , j ) ′ T h ) p\left(w=w_{O}\right)=\prod_{j=1}^{L(w)-1} \sigma\left([[n(w, j+1)=\operatorname{ch}(n(w, j))]] \cdot \mathbf{v}_{n(w, j)}^{\prime} T_{\mathbf{h}}\right) p(w=wO)=j=1∏L(w)−1σ([[n(w,j+1)=ch(n(w,j))]]⋅vn(w,j)′Th)
[[·]]是个1/-1函数,左子结点取1,右子结点取-1。再算cross entropy即可:
E = − log p ( w = w O ∣ w I ) = − ∑ j = 1 L ( w ) − 1 log σ ( [ [ ⋅ ] ] v j ′ T h ) E=-\log p\left(w=w_{O} | w_{I}\right)=-\sum_{j=1}^{L(w)-1} \log \sigma\left(\left[\left[\cdot\left]]\mathbf{v}_{j}^{\prime T} \mathbf{h}\right)\right.\right.\right. E=−logp(w=wO∣wI)=−j=1∑L(w)−1logσ([[⋅]]vj′Th)
这样就代替了softmax,复杂度从O(N)变成O(log N)。
细节推荐这篇博客。主要的几个步骤包括:
也就是说共现次数越少,对它们的相关性约束越小。
Glove 和 Word2vec 比较
Fasttext 最早其实是一个文本分类算法,后续加了一些改进来训练词向量。概括了几点:
之前那些方法构造的都是独立于上下文的 word embedding,也就是无论下游任务是什么,输入的 embedding 始终是固定的,这就无法解决一词多义,以及在不同语境下有不同表现的需求。所以后续的 ELMo,GPT 以及 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}\left(\log p\left(t_{k} | t_{1}, \ldots, t_{k-1} ; \vec{\Theta}_{L S T M}, \Theta_{s}\right)+\log p\left(t_{k} | t_{k+1}, \ldots, t_{N} ; \overleftarrow{\Theta}_{L S T M}, \Theta_{s}\right)\right) 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{E} \mathbf{L} \mathbf{M} \mathbf{o}_{k}^{t a s k}=\gamma^{t a s k} \sum_{j=0}^{L} s_{j}^{t a s k} \mathbf{h}_{k j}^{L M} 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)和语言模型 loss(L_1):
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]
1、Masked Language Model
所谓双向 LM,就是在预测当前词时同时考虑上文和下文,也就是
p ( t k ∣ t 1 , … , t k − 1 , t k + 1 , … , t N ) p\left(t_{k} | t_{1}, \ldots, t_{k-1}, t_{k+1}, \ldots, t_{N}\right) 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 没有实际意义,对其它词的影响也就相对较小。
2、 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 超参数,其余与预训练保持一致即可。
预训练好的 Bert 除了用于 fine-tuning 以外,还可以像 ELMo 一样作为特征抽取器,也就是直接用学习到的 word embeddings 当做其它模型的输入。目前看来最好的选择是最后四层向量拼接。
Bert 与 GPT 的区别:
Bert 最大的创新: