14. 自然语言处理:预训练

14.1 词嵌入(Word2vec)

自然语言中,词是意义的基本单元。词向量是用于表示单词意义的向量。 将单词映射到实向量的技术称为词嵌入。[8.5节]中使用独热向量来表示词,但它通常不是一个好选择,一个主要原因是独热向量不能编码词之间的相似性。
word2vec将每个词映射到一个固定长度的向量,能更好地表达不同词之间的相似性,此外,还可以避免独热编码由于词典过长发生的维度爆炸问题。其包含两个模型,跳元模型(skip-gram) [Mikolov et al., 2013b]连续词袋(CBOW) [Mikolov et al., 2013a],它们的训练依赖于条件概率,条件概率可以被看作是使用语料库中一些词来预测另一些单词。此外,跳元模型和连续词袋都是不带标签的自监督模型。

14.1.1 跳元模型(Skip-Gram)

跳元模型使用中心词预测上下文词。以文本序列“the”、“man”、“loves”、“his”、“son”为例。假设中心词选择“loves”,并将上下文窗口设置为2,跳元模型考虑生成上下文词“the”、“man”、“him”、“son”的条件概率:
14. 自然语言处理:预训练 - 图1%0A#card=math&code=P%28%22the%22%2C%22man%22%2C%22his%22%2C%22son%22%E2%88%A3%22loves%22%29%0A&id=QyjT0)
假设上下文词是在给定中心词的情况下独立生成的,上述条件概率可以重写为:
14. 自然语言处理:预训练 - 图2%E2%8B%85P(%22man%22%E2%88%A3%22loves%22)%E2%8B%85P(%22his%22%E2%88%A3%22loves%22)%E2%8B%85P(%22son%22%E2%88%A3%22loves%22)%0A#card=math&code=P%28%22the%22%E2%88%A3%22loves%22%29%E2%8B%85P%28%22man%22%E2%88%A3%22loves%22%29%E2%8B%85P%28%22his%22%E2%88%A3%22loves%22%29%E2%8B%85P%28%22son%22%E2%88%A3%22loves%22%29%0A&id=n2Nsf)
对于词典中索引为14. 自然语言处理:预训练 - 图3的任何词,分别用14. 自然语言处理:预训练 - 图414. 自然语言处理:预训练 - 图5表示其用作中心词上下文词时的向量。给定中心词14. 自然语言处理:预训练 - 图6(词典中的索引14. 自然语言处理:预训练 - 图7),生成任何上下文词14. 自然语言处理:预训练 - 图8(词典中的索引14. 自然语言处理:预训练 - 图9)的条件概率可以通过对向量点积的softmax操作来建模:
14. 自然语言处理:预训练 - 图10%3D%5Cfrac%7B%5Cexp%20%5Cleft(%5Cmathbf%7Bu%7D%7Bo%7D%5E%7B%5Ctop%7D%20%5Cmathbf%7Bv%7D%7Bc%7D%5Cright)%7D%7B%5Csum%7Bi%20%5Cin%20%5Cmathcal%7BV%7D%7D%20%5Cexp%20%5Cleft(%5Cmathbf%7Bu%7D%7Bi%7D%5E%7B%5Ctop%7D%20%5Cmathbf%7Bv%7D%7Bc%7D%5Cright)%7D%0A#card=math&code=P%5Cleft%28w%7Bo%7D%20%5Cmid%20w%7Bc%7D%5Cright%29%3D%5Cfrac%7B%5Cexp%20%5Cleft%28%5Cmathbf%7Bu%7D%7Bo%7D%5E%7B%5Ctop%7D%20%5Cmathbf%7Bv%7D%7Bc%7D%5Cright%29%7D%7B%5Csum%7Bi%20%5Cin%20%5Cmathcal%7BV%7D%7D%20%5Cexp%20%5Cleft%28%5Cmathbf%7Bu%7D%7Bi%7D%5E%7B%5Ctop%7D%20%5Cmathbf%7Bv%7D%7Bc%7D%5Cright%29%7D%0A&id=UPXSc)
给定长度为 14. 自然语言处理:预训练 - 图11 的文本序列, 时间步 14. 自然语言处理:预训练 - 图12 处的词表示为 14. 自然语言处理:预训练 - 图13%7D#card=math&code=w%5E%7B%28t%29%7D&id=U6Jd6) 。假设上下文词是在给定任何中心词的情况下独立生成的。对于上下文窗口 m , 跳元模型的似然函数是在给定任何中心词的情况下生成所有上下文词的 概率:
14. 自然语言处理:预训练 - 图14%7D%20%5Cmid%20w%5E%7B(t)%7D%5Cright)%0A#card=math&code=%5Cprod%7Bt%3D1%7D%5E%7BT%7D%20%5Cprod%7B-m%20%5Cleq%20j%20%5Cleq%20m%2C%20j%20%5Cneq%200%7D%20P%5Cleft%28w%5E%7B%28t%2Bj%29%7D%20%5Cmid%20w%5E%7B%28t%29%7D%5Cright%29%0A&id=jSiaL)
在训练跳元模型时,通过极大似然估计来学习模型参数。这相当于最小化以下损失函数:
14. 自然语言处理:预训练 - 图15%7D%20%5Cmid%20w%5E%7B(t)%7D%5Cright)%0A#card=math&code=-%5Csum%7Bt%3D1%7D%5E%7BT%7D%20%5Csum%7B-m%20%5Cleq%20j%20%5Cleq%20m%2C%20j%20%5Cneq%200%7D%20%5Clog%20P%5Cleft%28w%5E%7B%28t%2Bj%29%7D%20%5Cmid%20w%5E%7B%28t%29%7D%5Cright%29%0A&id=WSIP1)
当使用随机梯度下降来最小化损失时, 在每次迭代中可以随机抽样一个较短的子序列 来计算该子序列的梯度, 以更新模型参数。为了计算该梯度, 我们 需要获得对数条件概率关于中心词向量和上下文词向量的梯度。通常, 根据 (1), 涉及中心词 14. 自然语言处理:预训练 - 图16 和上下文词 14. 自然语言处理:预训练 - 图17 的对数条件概率为:
14. 自然语言处理:预训练 - 图18%3D%5Cmathbf%7Bu%7D%7Bo%7D%5E%7B%5Ctop%7D%20%5Cmathbf%7Bv%7D%7Bc%7D-%5Clog%20%5Cleft(%5Csum%7Bi%20%5Cin%20%5Cmathcal%7BV%7D%7D%20%5Cexp%20%5Cleft(%5Cmathbf%7Bu%7D%7Bi%7D%5E%7B%5Ctop%7D%20%5Cmathbf%7Bv%7D%7Bc%7D%5Cright)%5Cright)%0A#card=math&code=%5Clog%20P%5Cleft%28w%7Bo%7D%20%5Cmid%20w%7Bc%7D%5Cright%29%3D%5Cmathbf%7Bu%7D%7Bo%7D%5E%7B%5Ctop%7D%20%5Cmathbf%7Bv%7D%7Bc%7D-%5Clog%20%5Cleft%28%5Csum%7Bi%20%5Cin%20%5Cmathcal%7BV%7D%7D%20%5Cexp%20%5Cleft%28%5Cmathbf%7Bu%7D%7Bi%7D%5E%7B%5Ctop%7D%20%5Cmathbf%7Bv%7D%7Bc%7D%5Cright%29%5Cright%29%0A&id=WPTW0)
通过微分, 我们可以获得其相对于中心词向量 14. 自然语言处理:预训练 - 图19 的梯度为
14. 自然语言处理:预训练 - 图20%7D%7B%5Cpartial%20%5Cmathbf%7Bv%7D%7Bc%7D%7D%20%26%3D%5Cmathbf%7Bu%7D%7Bo%7D-%5Cfrac%7B%5Csum%7Bj%20%5Cin%20%5Cmathcal%7BV%7D%7D%20%5Cexp%20%5Cleft(%5Cmathbf%7Bu%7D%7Bj%7D%5E%7B%5Ctop%7D%20%5Cmathbf%7Bv%7D%7Bc%7D%5Cright)%20%5Cmathbf%7Bu%7D%7Bj%7D%7D%7B%5Csum%7Bi%20%5Cin%20%5Cmathcal%7BV%7D%7D%20%5Cexp%20%5Cleft(%5Cmathbf%7Bu%7D%7Bi%7D%5E%7B%5Ctop%7D%20%5Cmathbf%7Bv%7D%7Bc%7D%5Cright)%7D%20%5C%5C%0A%26%3D%5Cmathbf%7Bu%7D%7Bo%7D-%5Csum%7Bj%20%5Cin%20%5Cmathcal%7BV%7D%7D%5Cleft(%5Cfrac%7B%5Cexp%20%5Cleft(%5Cmathbf%7Bu%7D%7Bj%7D%5E%7B%5Ctop%7D%20%5Cmathbf%7Bv%7D%7Bc%7D%5Cright)%7D%7B%5Csum%7Bi%20%5Cin%20%5Cmathcal%7BV%7D%7D%20%5Cexp%20%5Cleft(%5Cmathbf%7Bu%7D%7Bi%7D%5E%7B%5Ctop%7D%20%5Cmathbf%7Bv%7D%7Bc%7D%5Cright)%7D%5Cright)%20%5Cmathbf%7Bu%7D%7Bj%7D%20%5C%5C%0A%26%3D%5Cmathbf%7Bu%7D%7Bo%7D-%5Csum%7Bj%20%5Cin%20%5Cmathcal%7BV%7D%7D%20P%5Cleft(w%7Bj%7D%20%5Cmid%20w%7Bc%7D%5Cright)%20%5Cmathbf%7Bu%7D%7Bj%7D%0A%5Cend%7Baligned%7D%0A#card=math&code=%5Cbegin%7Baligned%7D%0A%5Cfrac%7B%5Cpartial%20%5Clog%20P%5Cleft%28w%7Bo%7D%20%5Cmid%20w%7Bc%7D%5Cright%29%7D%7B%5Cpartial%20%5Cmathbf%7Bv%7D%7Bc%7D%7D%20%26%3D%5Cmathbf%7Bu%7D%7Bo%7D-%5Cfrac%7B%5Csum%7Bj%20%5Cin%20%5Cmathcal%7BV%7D%7D%20%5Cexp%20%5Cleft%28%5Cmathbf%7Bu%7D%7Bj%7D%5E%7B%5Ctop%7D%20%5Cmathbf%7Bv%7D%7Bc%7D%5Cright%29%20%5Cmathbf%7Bu%7D%7Bj%7D%7D%7B%5Csum%7Bi%20%5Cin%20%5Cmathcal%7BV%7D%7D%20%5Cexp%20%5Cleft%28%5Cmathbf%7Bu%7D%7Bi%7D%5E%7B%5Ctop%7D%20%5Cmathbf%7Bv%7D%7Bc%7D%5Cright%29%7D%20%5C%5C%0A%26%3D%5Cmathbf%7Bu%7D%7Bo%7D-%5Csum%7Bj%20%5Cin%20%5Cmathcal%7BV%7D%7D%5Cleft%28%5Cfrac%7B%5Cexp%20%5Cleft%28%5Cmathbf%7Bu%7D%7Bj%7D%5E%7B%5Ctop%7D%20%5Cmathbf%7Bv%7D%7Bc%7D%5Cright%29%7D%7B%5Csum%7Bi%20%5Cin%20%5Cmathcal%7BV%7D%7D%20%5Cexp%20%5Cleft%28%5Cmathbf%7Bu%7D%7Bi%7D%5E%7B%5Ctop%7D%20%5Cmathbf%7Bv%7D%7Bc%7D%5Cright%29%7D%5Cright%29%20%5Cmathbf%7Bu%7D%7Bj%7D%20%5C%5C%0A%26%3D%5Cmathbf%7Bu%7D%7Bo%7D-%5Csum%7Bj%20%5Cin%20%5Cmathcal%7BV%7D%7D%20P%5Cleft%28w%7Bj%7D%20%5Cmid%20w%7Bc%7D%5Cright%29%20%5Cmathbf%7Bu%7D%7Bj%7D%0A%5Cend%7Baligned%7D%0A&id=zhDYs)
对词典中索引为14. 自然语言处理:预训练 - 图21的词进行训练后,得到14. 自然语言处理:预训练 - 图22(作为中心词)和14. 自然语言处理:预训练 - 图23(作为上下文词)两个词向量。在自然语言处理应用中,跳元模型的中心词向量通常用作词表示。

14.1.2 连续词袋(CBOW)模型

连续词袋模型使用上下文词来预测中心词。在“loves”为中心词且上下文窗口为2的情况下,连续词袋模型考虑:
14. 自然语言处理:预训练 - 图24%0A#card=math&code=P%28%22loves%22%E2%88%A3%22the%22%2C%22man%22%2C%22his%22%2C%22son%22%29%0A&id=pe69k)
对于字典中索引 14. 自然语言处理:预训练 - 图25 的任意词, 分别用 14. 自然语言处理:预训练 - 图2614. 自然语言处理:预训练 - 图27 表示用作上下文词和中心词的向量。给定上下文词 14. 自然语言处理:预训练 - 图28 生成任意中心词 14. 自然语言处理:预训练 - 图29 的条件概率可以由以下公式建模:
14. 自然语言处理:预训练 - 图30%3D%5Cfrac%7B%5Cexp%20%5Cleft(%5Cfrac%7B1%7D%7B2%20m%7D%20%5Cmathbf%7Bu%7D%7Bc%7D%5E%7B%5Ctop%7D%5Cleft(%5Cmathbf%7Bv%7D%7Bo%7B1%7D%7D%2B%5Cldots%2C%2B%5Cmathbf%7Bv%7D%7Bo%7B2%20m%7D%7D%5Cright)%5Cright)%7D%7B%5Csum%7Bi%20%5Cin%20%5Cmathcal%7BV%7D%7D%20%5Cexp%20%5Cleft(%5Cfrac%7B1%7D%7B2%20m%7D%20%5Cmathbf%7Bu%7D%7Bi%7D%5E%7B%5Ctop%7D%5Cleft(%5Cmathbf%7Bv%7D%7Bo%7B1%7D%7D%2B%5Cldots%2C%2B%5Cmathbf%7Bv%7D%7Bo%7B2%20m%7D%7D%5Cright)%5Cright)%7D%0A#card=math&code=P%5Cleft%28w%7Bc%7D%20%5Cmid%20w%7Bo%7B1%7D%7D%2C%20%5Cldots%2C%20w%7Bo%7B2%20m%7D%7D%5Cright%29%3D%5Cfrac%7B%5Cexp%20%5Cleft%28%5Cfrac%7B1%7D%7B2%20m%7D%20%5Cmathbf%7Bu%7D%7Bc%7D%5E%7B%5Ctop%7D%5Cleft%28%5Cmathbf%7Bv%7D%7Bo%7B1%7D%7D%2B%5Cldots%2C%2B%5Cmathbf%7Bv%7D%7Bo%7B2%20m%7D%7D%5Cright%29%5Cright%29%7D%7B%5Csum%7Bi%20%5Cin%20%5Cmathcal%7BV%7D%7D%20%5Cexp%20%5Cleft%28%5Cfrac%7B1%7D%7B2%20m%7D%20%5Cmathbf%7Bu%7D%7Bi%7D%5E%7B%5Ctop%7D%5Cleft%28%5Cmathbf%7Bv%7D%7Bo%7B1%7D%7D%2B%5Cldots%2C%2B%5Cmathbf%7Bv%7D%7Bo%7B2%20m%7D%7D%5Cright%29%5Cright%29%7D%0A&id=Ng4no)
给定长度为 14. 自然语言处理:预训练 - 图31 的文本序列, 其中时间步 14. 自然语言处理:预训练 - 图32 处的词表示为 14. 自然语言处理:预训练 - 图33%7D#card=math&code=w%5E%7B%28t%29%7D&id=ivFPy) 。对于上下文窗口 14. 自然语言处理:预训练 - 图34 , 连续 词袋模型的似然函数是在给定其上下文词的情况下生成所有中心词的概率:
![](https://g.yuque.com/gr/latex?%5Cprod
%7Bt%3D1%7D%5E%7BT%7D%20P%5Cleft(w%5E%7B(t)%7D%20%5Cmid%20w%5E%7B(t-m)%7D%2C%20%5Cldots%2C%20w%5E%7B(t-1)%7D%2C%20w%5E%7B(t%2B1)%7D%2C%20%5Cldots%2C%20w%5E%7B(t%2Bm)%7D%5Cright)%0A#card=math&code=%5Cprod%7Bt%3D1%7D%5E%7BT%7D%20P%5Cleft%28w%5E%7B%28t%29%7D%20%5Cmid%20w%5E%7B%28t-m%29%7D%2C%20%5Cldots%2C%20w%5E%7B%28t-1%29%7D%2C%20w%5E%7B%28t%2B1%29%7D%2C%20%5Cldots%2C%20w%5E%7B%28t%2Bm%29%7D%5Cright%29%0A&id=K63GE)
连续词袋模型的最大似然估计等价于最小化以下损失函数:
![](https://g.yuque.com/gr/latex?-%5Csum
%7Bt%3D1%7D%5E%7BT%7D%20%5Clog%20P%5Cleft(w%5E%7B(t)%7D%20%5Cmid%20w%5E%7B(t-m)%7D%2C%20%5Cldots%2C%20w%5E%7B(t-1)%7D%2C%20w%5E%7B(t%2B1)%7D%2C%20%5Cldots%2C%20w%5E%7B(t%2Bm)%7D%5Cright)%0A#card=math&code=-%5Csum%7Bt%3D1%7D%5E%7BT%7D%20%5Clog%20P%5Cleft%28w%5E%7B%28t%29%7D%20%5Cmid%20w%5E%7B%28t-m%29%7D%2C%20%5Cldots%2C%20w%5E%7B%28t-1%29%7D%2C%20w%5E%7B%28t%2B1%29%7D%2C%20%5Cldots%2C%20w%5E%7B%28t%2Bm%29%7D%5Cright%29%0A&id=Ov3YY)
为了简洁起见, 我们设为 ![](https://g.yuque.com/gr/latex?%5Cmathcal%7BW%7D
%7Bo%7D%3D%5Cleft%5C%7Bw%7Bo%201%7D%2C%20%5Cldots%2C%20w%7Bo%7B2%20m%7D%7D%5Cright%5C%7D#card=math&code=%5Cmathcal%7BW%7D%7Bo%7D%3D%5Cleft%5C%7Bw%7Bo%201%7D%2C%20%5Cldots%2C%20w%7Bo%7B2%20m%7D%7D%5Cright%5C%7D&id=uP3NY) 和![](https://g.yuque.com/gr/latex?%5Coverline%7B%5Cmathbf%7Bv%7D%7D%7Bo%7D%3D%5Cleft(%5Cmathbf%7Bv%7D%7Bo%7B1%7D%7D%2B%5Cldots%2C%2B%5Cmathbf%7Bv%7D%7Bo%7B2%20m%7D%7D%5Cright)%20%2F(2%20m)#card=math&code=%5Coverline%7B%5Cmathbf%7Bv%7D%7D%7Bo%7D%3D%5Cleft%28%5Cmathbf%7Bv%7D%7Bo%7B1%7D%7D%2B%5Cldots%2C%2B%5Cmathbf%7Bv%7D%7Bo%7B2%20m%7D%7D%5Cright%29%20%2F%282%20m%29&id=sfcaN) 。通过微分, 我们可以获得其关于任意上下文词向量 ![](https://g.yuque.com/gr/latex?%5Cmathbf%7Bv%7D%7Bo%7Bi%7D%7D(i%3D1%2C%20%5Cldots%2C%202%20m%20%20%EF%BC%89#card=math&code=%5Cmathbf%7Bv%7D%7Bo%7Bi%7D%7D%28i%3D1%2C%20%5Cldots%2C%202%20m%20%20%EF%BC%89&id=ziSSs)的梯度, 如下:
![](https://g.yuque.com/gr/latex?%5Cfrac%7B%5Cpartial%20%5Clog%20P%5Cleft(w
%7Bc%7D%20%5Cmid%20%5Cmathcal%7BW%7D%7Bo%7D%5Cright)%7D%7B%5Cpartial%20%5Cmathbf%7Bv%7D%7Bo%7Bi%7D%7D%7D%3D%5Cfrac%7B1%7D%7B2%20m%7D%5Cleft(%5Cmathbf%7Bu%7D%7Bc%7D-%5Csum%7Bj%20%5Cin%20%5Cmathcal%7BV%7D%7D%20%5Cfrac%7B%5Cexp%20%5Cleft(%5Cmathbf%7Bu%7D%7Bj%7D%5E%7B%5Ctop%7D%20%5Coverline%7B%5Cmathbf%7Bv%7D%7D%7Bo%7D%5Cright)%20%5Cmathbf%7Bu%7D%7Bj%7D%7D%7B%5Csum%7Bi%20%5Cin%20%5Cmathcal%7BV%7D%7D%20%5Cexp%20%5Cleft(%5Cmathbf%7Bu%7D%7Bi%7D%5E%7B%5Ctop%7D%20%5Coverline%7B%5Cmathbf%7Bv%7D%7D%7Bo%7D%5Cright)%7D%5Cright)%3D%5Cfrac%7B1%7D%7B2%20m%7D%5Cleft(%5Cmathbf%7Bu%7D%7Bc%7D-%5Csum%7Bj%20%5Cin%20%5Cmathcal%7BV%7D%7D%20P%5Cleft(w%7Bj%7D%20%5Cmid%20%5Cmathcal%7BW%7D%7Bo%7D%5Cright)%20%5Cmathbf%7Bu%7D%7Bj%7D%5Cright)%0A#card=math&code=%5Cfrac%7B%5Cpartial%20%5Clog%20P%5Cleft%28w%7Bc%7D%20%5Cmid%20%5Cmathcal%7BW%7D%7Bo%7D%5Cright%29%7D%7B%5Cpartial%20%5Cmathbf%7Bv%7D%7Bo%7Bi%7D%7D%7D%3D%5Cfrac%7B1%7D%7B2%20m%7D%5Cleft%28%5Cmathbf%7Bu%7D%7Bc%7D-%5Csum%7Bj%20%5Cin%20%5Cmathcal%7BV%7D%7D%20%5Cfrac%7B%5Cexp%20%5Cleft%28%5Cmathbf%7Bu%7D%7Bj%7D%5E%7B%5Ctop%7D%20%5Coverline%7B%5Cmathbf%7Bv%7D%7D%7Bo%7D%5Cright%29%20%5Cmathbf%7Bu%7D%7Bj%7D%7D%7B%5Csum%7Bi%20%5Cin%20%5Cmathcal%7BV%7D%7D%20%5Cexp%20%5Cleft%28%5Cmathbf%7Bu%7D%7Bi%7D%5E%7B%5Ctop%7D%20%5Coverline%7B%5Cmathbf%7Bv%7D%7D%7Bo%7D%5Cright%29%7D%5Cright%29%3D%5Cfrac%7B1%7D%7B2%20m%7D%5Cleft%28%5Cmathbf%7Bu%7D%7Bc%7D-%5Csum%7Bj%20%5Cin%20%5Cmathcal%7BV%7D%7D%20P%5Cleft%28w%7Bj%7D%20%5Cmid%20%5Cmathcal%7BW%7D%7Bo%7D%5Cright%29%20%5Cmathbf%7Bu%7D_%7Bj%7D%5Cright%29%0A&id=ycwqq)
连续词袋模型通常使用上下文词向量作为词 表示。

14.2. 近似训练

在[14.1]节中使用softmax来计算概率,两个模型的梯度计算都包含了求和。在一个词典上(通常有几十万或数百万个单词)求和梯度的计算成本是巨大的!下面以跳元模型为例介绍两种近似的训练方法。

14.2.1 负采样

负采样修改了原目标函数。给定中心词14. 自然语言处理:预训练 - 图35的上下文窗口,上下文词14. 自然语言处理:预训练 - 图36来自该上下文窗口被认为是由下式建模概率的事件:
14. 自然语言处理:预训练 - 图37%3D%CF%83(%5Cmathbf%7Bu%7D%5E%7B%E2%8A%A4%7D%7Bo%7D%5Cmathbf%7Bv%7D%7Bc%7D)%0A#card=math&code=P%28D%3D1%E2%88%A3w%7Bc%7D%2Cw%7Bo%7D%29%3D%CF%83%28%5Cmathbf%7Bu%7D%5E%7B%E2%8A%A4%7D%7Bo%7D%5Cmathbf%7Bv%7D%7Bc%7D%29%0A&id=JiJ8s)
其中,14. 自然语言处理:预训练 - 图3814. 自然语言处理:预训练 - 图39表示词在词典中的索引,σ为sigmoid激活函数,14. 自然语言处理:预训练 - 图4014. 自然语言处理:预训练 - 图41分别表示词典中索引为14. 自然语言处理:预训练 - 图42的任何词用作中心词和上下文词时的向量。
下文词是在给定任何中心词的情况下独立生成的。对于上下文窗口m,跳元模型的似然函数是在给定任何中心词的情况下生成所有上下文词的概率:
14. 自然语言处理:预训练 - 图43%7D%2C%20w%5E%7B(t%2Bj)%7D%5Cright)%0A#card=math&code=%5Cprod%7Bt%3D1%7D%5E%7BT%7D%20%5Cprod%7B-m%20%5Cleq%20j%20%5Cleq%20m%2C%20j%20%5Cneq%200%7D%20P%5Cleft%28D%3D1%20%5Cmid%20w%5E%7B%28t%29%7D%2C%20w%5E%7B%28t%2Bj%29%7D%5Cright%29%0A&id=XW7ai)
上式仅考虑了正样本事件。为了使目标函数更有意义, 负采样添加从预定义分布中采样的负样本。
14. 自然语言处理:预训练 - 图44表示上下文词14. 自然语言处理:预训练 - 图45来自中心词14. 自然语言处理:预训练 - 图46的上下文窗口的事件。此外, 从预定义分布 14. 自然语言处理:预训练 - 图47#card=math&code=P%28w%29&id=okpDl) 中采样 14. 自然语言处理:预训练 - 图48 个不是来自这个上下文窗口的噪声词。用 14. 自然语言处理:预训练 - 图49 表示噪声词 14. 自然语言处理:预训练 - 图50#card=math&code=w%7Bk%7D%28k%3D1%2C%20%5Cldots%2C%20K%29&id=SziA4) 不是来自 ![](https://g.yuque.com/gr/latex?w%7Bc%7D#card=math&code=w%7Bc%7D&id=CdYRX) 的上下文窗口的事件。假设正例和负例 ![](https://g.yuque.com/gr/latex?S%2C%20N%7B1%7D%2C%20%5Cldots%2C%20N%7BK%7D#card=math&code=S%2C%20N%7B1%7D%2C%20%5Cldots%2C%20N%7BK%7D&id=ZHR3U) 的这些事件是相互独立的。负采样将上式中的联合概率重写为:
14. 自然语言处理:预训练 - 图51%7D%20%5Cmid%20w%5E%7B(t)%7D%5Cright)%3DP%5Cleft(D%3D1%20%5Cmid%20w%5E%7B(t)%7D%2C%20w%5E%7B(t%2Bj)%7D%5Cright)%20%5Cprod
%7Bk%3D1%2C%20w%7Bk%7D%20%5Csim%20P(w)%7D%5E%7BK%7D%20P%5Cleft(D%3D0%20%5Cmid%20w%5E%7B(t)%7D%2C%20w%7Bk%7D%5Cright)%0A#card=math&code=P%5Cleft%28w%5E%7B%28t%2Bj%29%7D%20%5Cmid%20w%5E%7B%28t%29%7D%5Cright%29%3DP%5Cleft%28D%3D1%20%5Cmid%20w%5E%7B%28t%29%7D%2C%20w%5E%7B%28t%2Bj%29%7D%5Cright%29%20%5Cprod%7Bk%3D1%2C%20w%7Bk%7D%20%5Csim%20P%28w%29%7D%5E%7BK%7D%20P%5Cleft%28D%3D0%20%5Cmid%20w%5E%7B%28t%29%7D%2C%20w%7Bk%7D%5Cright%29%0A&id=PeQSo)
分别用 ![](https://g.yuque.com/gr/latex?i
%7Bt%7D#card=math&code=i%7Bt%7D&id=jG7rx) 和 ![](https://g.yuque.com/gr/latex?h%7Bk%7D#card=math&code=h%7Bk%7D&id=HRkdx) 表示词 14. 自然语言处理:预训练 - 图52%7D#card=math&code=w%5E%7B%28t%29%7D&id=AeWYa) 和噪声词 ![](https://g.yuque.com/gr/latex?w%7Bk%7D#card=math&code=w%7Bk%7D&id=myz7v) 在文本序列的时间步 14. 自然语言处理:预训练 - 图53 处的索引。 上式的对数损失为:
14. 自然语言处理:预训练 - 图54%7D%20%5Cmid%20w%5E%7B(t)%7D%5Cright)%20%26%3D-%5Clog%20P%5Cleft(D%3D1%20%5Cmid%20w%5E%7B(t)%7D%2C%20w%5E%7B(t%2Bj)%7D%5Cright)-%5Csum
%7Bk%3D1%2C%20w%7Bk%7D%20%5Csim%20P(w)%7D%5E%7BK%7D%20%5Clog%20P%5Cleft(D%3D0%20%5Cmid%20w%5E%7B(t)%7D%2C%20w%7Bk%7D%5Cright)%20%5C%5C%0A%26%3D-%5Clog%20%5Csigma%5Cleft(%5Cmathbf%7Bu%7D%7Bi%7Bt%2Bj%7D%7D%5E%7B%5Ctop%7D%20%5Cmathbf%7Bv%7D%7Bi%7Bt%7D%7D%5Cright)-%5Csum%7Bk%3D1%2C%20w%7Bk%7D%20%5Csim%20P(w)%7D%5E%7BK%7D%20%5Clog%20%5Cleft(1-%5Csigma%5Cleft(%5Cmathbf%7Bu%7D%7Bh%7Bk%7D%7D%5E%7B%5Ctop%7D%20%5Cmathbf%7Bv%7D%7Bi%7Bt%7D%7D%5Cright)%5Cright)%20%5C%5C%0A%26%3D-%5Clog%20%5Csigma%5Cleft(%5Cmathbf%7Bu%7D%7Bi%7Bt%2Bj%7D%7D%5E%7B%5Ctop%7D%20%5Cmathbf%7Bv%7D%7Bi%7Bt%7D%7D%5Cright)-%5Csum%7Bk%3D1%2C%20w%7Bk%7D%20%5Csim%20P(w)%7D%5E%7BK%7D%20%5Clog%20%5Csigma%5Cleft(-%5Cmathbf%7Bu%7D%7Bh%7Bk%7D%7D%5E%7B%5Ctop%7D%20%5Cmathbf%7Bv%7D%7Bi%7Bt%7D%7D%5Cright)%20.%0A%5Cend%7Baligned%7D%0A#card=math&code=%5Cbegin%7Baligned%7D%0A-%5Clog%20P%5Cleft%28w%5E%7B%28t%2Bj%29%7D%20%5Cmid%20w%5E%7B%28t%29%7D%5Cright%29%20%26%3D-%5Clog%20P%5Cleft%28D%3D1%20%5Cmid%20w%5E%7B%28t%29%7D%2C%20w%5E%7B%28t%2Bj%29%7D%5Cright%29-%5Csum%7Bk%3D1%2C%20w%7Bk%7D%20%5Csim%20P%28w%29%7D%5E%7BK%7D%20%5Clog%20P%5Cleft%28D%3D0%20%5Cmid%20w%5E%7B%28t%29%7D%2C%20w%7Bk%7D%5Cright%29%20%5C%5C%0A%26%3D-%5Clog%20%5Csigma%5Cleft%28%5Cmathbf%7Bu%7D%7Bi%7Bt%2Bj%7D%7D%5E%7B%5Ctop%7D%20%5Cmathbf%7Bv%7D%7Bi%7Bt%7D%7D%5Cright%29-%5Csum%7Bk%3D1%2C%20w%7Bk%7D%20%5Csim%20P%28w%29%7D%5E%7BK%7D%20%5Clog%20%5Cleft%281-%5Csigma%5Cleft%28%5Cmathbf%7Bu%7D%7Bh%7Bk%7D%7D%5E%7B%5Ctop%7D%20%5Cmathbf%7Bv%7D%7Bi%7Bt%7D%7D%5Cright%29%5Cright%29%20%5C%5C%0A%26%3D-%5Clog%20%5Csigma%5Cleft%28%5Cmathbf%7Bu%7D%7Bi%7Bt%2Bj%7D%7D%5E%7B%5Ctop%7D%20%5Cmathbf%7Bv%7D%7Bi%7Bt%7D%7D%5Cright%29-%5Csum%7Bk%3D1%2C%20w%7Bk%7D%20%5Csim%20P%28w%29%7D%5E%7BK%7D%20%5Clog%20%5Csigma%5Cleft%28-%5Cmathbf%7Bu%7D%7Bh%7Bk%7D%7D%5E%7B%5Ctop%7D%20%5Cmathbf%7Bv%7D%7Bi_%7Bt%7D%7D%5Cright%29%20.%0A%5Cend%7Baligned%7D%0A&id=Rgb3t)
可以看到每个训练步的梯度计算成本与词表大小无关,而是线性依赖于超参数14. 自然语言处理:预训练 - 图55

14.2.2. 层序Softmax

层序Softmax(hierarchical softmax)使用二叉树,其中树的每个叶节点表示词表14. 自然语言处理:预训练 - 图56中的一个词。14. 自然语言处理:预训练 - 图57
14. 自然语言处理:预训练 - 图58#card=math&code=L%28w%29&id=NLNNK) 表示二叉树中字 14. 自然语言处理:预训练 - 图59 的从根节点到叶节点的路径上的节点数(包括两端)。例 如, 上图 14. 自然语言处理:预训练 - 图60%3D4#card=math&code=L%5Cleft%28w%7B3%7D%5Cright%29%3D4&id=TE3c7) 。设 14. 自然语言处理:预训练 - 图61#card=math&code=n%28w%2C%20j%29&id=LEcHj) 为该路径上的 14. 自然语言处理:预训练 - 图62 节点, 其上下文字向量为 ![](https://g.yuque.com/gr/latex?%5Cmathbf%7Bu%7D%7Bn(w%2C%20j)%7D#card=math&code=%5Cmathbf%7Bu%7D%7Bn%28w%2C%20j%29%7D&id=nzHLa) 。分层softmax将 (1).中的条件概率近似为:
![](https://g.yuque.com/gr/latex?P%5Cleft(w
%7Bo%7D%20%5Cmid%20w%7Bc%7D%5Cright)%3D%5Cprod%7Bj%3D1%7D%5E%7BL%5Cleft(w%7Bo%7D%5Cright)-1%7D%20%5Csigma%5Cleft(%5B%20n%5Cleft(w%7Bo%7D%2C%20j%2B1%5Cright)%3D%5Coperatorname%7BleftChild%7D%5Cleft(n%5Cleft(w%7Bo%7D%2C%20j%5Cright)%5Cright)%20%5D%20%5Ccdot%20%5Cmathbf%7Bu%7D%7Bn%5Cleft(w%7Bo%7D%2C%20j%5Cright)%7D%5E%7B%5Ctop%7D%20%5Cmathbf%7Bv%7D%7Bc%7D%5Cright)%0A#card=math&code=P%5Cleft%28w%7Bo%7D%20%5Cmid%20w%7Bc%7D%5Cright%29%3D%5Cprod%7Bj%3D1%7D%5E%7BL%5Cleft%28w%7Bo%7D%5Cright%29-1%7D%20%5Csigma%5Cleft%28%5B%20n%5Cleft%28w%7Bo%7D%2C%20j%2B1%5Cright%29%3D%5Coperatorname%7BleftChild%7D%5Cleft%28n%5Cleft%28w%7Bo%7D%2C%20j%5Cright%29%5Cright%29%20%5D%20%5Ccdot%20%5Cmathbf%7Bu%7D%7Bn%5Cleft%28w%7Bo%7D%2C%20j%5Cright%29%7D%5E%7B%5Ctop%7D%20%5Cmathbf%7Bv%7D%7Bc%7D%5Cright%29%0A&id=cgMg6)
如果x为真, [x]=1 ;否则 [x]=-1。为了说明, 让我们计算上图中给定词 ![](https://g.yuque.com/gr/latex?w
%7Bc%7D#card=math&code=w%7Bc%7D&id=DOHS5) 生成词 ![](https://g.yuque.com/gr/latex?w%7B3%7D#card=math&code=w%7B3%7D&id=oZ5JF) 的条件概率。这需要 ![](https://g.yuque.com/gr/latex?w%7Bc%7D#card=math&code=w%7Bc%7D&id=PZgs7) 的词向量 ![](https://g.yuque.com/gr/latex?%5Cmathbf%7Bv%7D%7Bc%7D#card=math&code=%5Cmathbf%7Bv%7D%7Bc%7D&id=w616A) 和从根到 ![](https://g.yuque.com/gr/latex?w%7B3%7D#card=math&code=w%7B3%7D&id=HS1ce) 的路径上的非叶节点向 量之间的点积, 该路径依次向左、向右和向左遍历:
![](https://g.yuque.com/gr/latex?P%5Cleft(w
%7B3%7D%20%5Cmid%20w%7Bc%7D%5Cright)%3D%5Csigma%5Cleft(%5Cmathbf%7Bu%7D%7Bn%5Cleft(w%7B3%7D%2C%201%5Cright)%7D%5E%7B%5Ctop%7D%20%5Cmathbf%7Bv%7D%7Bc%7D%5Cright)%20%5Ccdot%20%5Csigma%5Cleft(-%5Cmathbf%7Bu%7D%7Bn%5Cleft(w%7B3%7D%2C%202%5Cright)%7D%5E%7B%5Ctop%7D%20%5Cmathbf%7Bv%7D%7Bc%7D%5Cright)%20%5Ccdot%20%5Csigma%5Cleft(%5Cmathbf%7Bu%7D%7Bn(w%2C%203)%7D%5E%7B%5Ctop%7D%20%5Cmathbf%7Bv%7D%7Bc%7D%5Cright)%20.%0A#card=math&code=P%5Cleft%28w%7B3%7D%20%5Cmid%20w%7Bc%7D%5Cright%29%3D%5Csigma%5Cleft%28%5Cmathbf%7Bu%7D%7Bn%5Cleft%28w%7B3%7D%2C%201%5Cright%29%7D%5E%7B%5Ctop%7D%20%5Cmathbf%7Bv%7D%7Bc%7D%5Cright%29%20%5Ccdot%20%5Csigma%5Cleft%28-%5Cmathbf%7Bu%7D%7Bn%5Cleft%28w%7B3%7D%2C%202%5Cright%29%7D%5E%7B%5Ctop%7D%20%5Cmathbf%7Bv%7D%7Bc%7D%5Cright%29%20%5Ccdot%20%5Csigma%5Cleft%28%5Cmathbf%7Bu%7D%7Bn%28w%2C%203%29%7D%5E%7B%5Ctop%7D%20%5Cmathbf%7Bv%7D%7Bc%7D%5Cright%29%20.%0A&id=pIoeA)
14. 自然语言处理:预训练 - 图63%2B%5Csigma(-x)%3D1#card=math&code=%5Csigma%28x%29%2B%5Csigma%28-x%29%3D1&id=Uiea1) , 它认为基于任意词 ![](https://g.yuque.com/gr/latex?w
%7Bc%7D#card=math&code=w%7Bc%7D&id=jtM4H) 生成词表 14. 自然语言处理:预训练 - 图64 中所有词的条件概率总和为 1 :
![](https://g.yuque.com/gr/latex?%5Csum
%7Bw%20%5Cin%20%5Cmathcal%7BV%7D%7D%20P%5Cleft(w%20%5Cmid%20w%7Bc%7D%5Cright)%3D1%0A#card=math&code=%5Csum%7Bw%20%5Cin%20%5Cmathcal%7BV%7D%7D%20P%5Cleft%28w%20%5Cmid%20w%7Bc%7D%5Cright%29%3D1%0A&id=aAjKK)
由于二叉树结构, ![](https://g.yuque.com/gr/latex?L%5Cleft(w
%7Bo%7D%5Cright)-1#card=math&code=L%5Cleft%28w%7Bo%7D%5Cright%29-1&id=oFtFu) 大约与 ![](https://g.yuque.com/gr/latex?%5Cmathcal%7BO%7D%5Cleft(%5Clog%20%7B2%7D%7C%5Cmathcal%7BV%7D%7C%5Cright)#card=math&code=%5Cmathcal%7BO%7D%5Cleft%28%5Clog%20_%7B2%7D%7C%5Cmathcal%7BV%7D%7C%5Cright%29&id=uBTA3) 是一个数量级。

14.3. 用于预训练词嵌入的数据集

下面以跳元模型和负采样为例来实现word2vec模型。

14.3.1 读取数据集

  1. import math
  2. import os
  3. import random
  4. import torch
  5. from d2l import torch as d2l
  6. #@save
  7. d2l.DATA_HUB['ptb'] = (d2l.DATA_URL + 'ptb.zip', # 读取华尔街日报文章数据集
  8. '319d85e578af0cdc590547f26231e4e31cdf1e42')
  9. #@save
  10. def read_ptb():
  11. """将PTB数据集加载到文本行的列表中"""
  12. data_dir = d2l.download_extract('ptb')
  13. with open(os.path.join(data_dir, 'ptb.train.txt')) as f:
  14. raw_text = f.read()
  15. return [line.split() for line in raw_text.split('\n')]
  16. sentences = read_ptb() # 获取数据构成的列表
  17. vocab = d2l.Vocab(sentences, min_freq=10)# 构建词表,并将出现次数小于10次的单词设置为<unk>

14.3.2 下采样

“the”、“a”和“in”等高频词提供的有用信息很少,训练速度很慢。因此,当训练词嵌入模型时,可以对高频单词进行下采样 [Mikolov et al., 2013b]。数据集中的每个词14. 自然语言处理:预训练 - 图65将有概率地被丢弃:
14. 自然语言处理:预训练 - 图66%3Dmax(1-%5Csqrt%7B%5Cfrac%7Bt%7D%7Bf(w%7Bi%7D)%7D%7D%2C0)%0A#card=math&code=P%28w%7Bi%7D%29%3Dmax%281-%5Csqrt%7B%5Cfrac%7Bt%7D%7Bf%28w%7Bi%7D%29%7D%7D%2C0%29%0A&id=lAaOE)
![](https://g.yuque.com/gr/latex?f(w
%7Bi%7D)#card=math&code=f%28w%7Bi%7D%29&id=gZPYB)是![](https://g.yuque.com/gr/latex?w%7Bi%7D#card=math&code=w%7Bi%7D&id=T5knw)的词数与数据集中总词数的比率,14. 自然语言处理:预训练 - 图67是超参数(在实验中为10-4)。可以看到只有当![](https://g.yuque.com/gr/latex?f(w%7Bi%7D)%3Et#card=math&code=f%28w%7Bi%7D%29%3Et&id=c723h)时,高频词![](https://g.yuque.com/gr/latex?w%7Bi%7D#card=math&code=w_%7Bi%7D&id=gB0dw)才能被丢弃,且该词的相对比率越高,被丢弃的概率就越大。

  1. #@save
  2. def subsample(sentences, vocab):
  3. """下采样高频词"""
  4. # 排除未知词元'<unk>'
  5. sentences = [[token for token in line if vocab[token] != vocab.unk]
  6. for line in sentences]
  7. counter = d2l.count_corpus(sentences)
  8. num_tokens = sum(counter.values())
  9. # 如果在下采样期间保留词元,则返回True。这里对公式进行了变形,意义是等价的
  10. def keep(token):
  11. return(random.uniform(0, 1) <
  12. math.sqrt(1e-4 / counter[token] * num_tokens))
  13. return ([[token for token in line if keep(token)] for line in sentences],counter)
  14. subsampled, counter = subsample(sentences, vocab)

下面的代码片段绘制了下采样前后每句话的词元数量。下采样通过删除高频词来显著缩短句子,这将使训练加速。

  1. d2l.show_list_len_pair_hist(
  2. ['origin', 'subsampled'], '# tokens per sentence',
  3. 'count', sentences, subsampled);

output_word-embedding-dataset_f77071_42_0.svg

  1. def compare_counts(token):
  2. return (f'"{token}"的数量:'
  3. f'之前={sum([l.count(token) for l in sentences])}, '
  4. f'之后={sum([l.count(token) for l in subsampled])}')
  5. # 高频词的数量被极大压缩了
  6. compare_counts('the') # '"the"的数量:之前=50770, 之后=2072'
  7. # 低频词的数量没有变化
  8. compare_counts('join') # '"join"的数量:之前=45, 之后=45'

在下采样之后,我们将词元映射到它们在语料库中的索引。

  1. corpus = [vocab[line] for line in subsampled]

14.3.3. 中心词和上下文词的提取

  1. #@save
  2. def get_centers_and_contexts(corpus, max_window_size):
  3. """返回跳元模型中的中心词和上下文词列表,对应元素组成特征-标签对"""
  4. centers, contexts = [], []
  5. for line in corpus:
  6. # 要形成“中心词-上下文词”对,每个句子至少需要有2个词
  7. if len(line) < 2:
  8. continue
  9. centers += line
  10. for i in range(len(line)): # 上下文窗口中间i
  11. window_size = random.randint(1, max_window_size)
  12. indices = list(range(max(0, i - window_size),
  13. min(len(line), i + 1 + window_size)))
  14. # 从上下文词中排除中心词
  15. indices.remove(i)
  16. contexts.append([line[idx] for idx in indices])
  17. return centers, contexts

在PTB数据集上进行训练时,我们将最大上下文窗口大小设置为5。下面提取数据集中的所有中心词及其上下文词。

  1. all_centers, all_contexts = get_centers_and_contexts(corpus, 5)

14.3.4. 负采样

使用负采样进行近似训练。为了根据预定义的分布对噪声词进行采样,定义RandomGenerator类,采样分布通过sampling_weights传递。

  1. #@save
  2. class RandomGenerator:
  3. """根据n个采样权重在{1,...,n}中随机抽取"""
  4. def __init__(self, sampling_weights):
  5. self.population = list(range(1, len(sampling_weights) + 1))
  6. self.sampling_weights = sampling_weights
  7. self.candidates = []
  8. self.i = 0
  9. def draw(self):
  10. if self.i == len(self.candidates):
  11. # 缓存k个随机采样结果
  12. self.candidates = random.choices(
  13. self.population, self.sampling_weights, k=10000)
  14. self.i = 0
  15. self.i += 1
  16. return self.candidates[self.i - 1]

例如,绘制10个随机变量X,采样概率为P(X=1)=2/9,P(X=2)=3/9和P(X=3)=4/9,如下所示。

  1. #@save
  2. generator = RandomGenerator([2, 3, 4])
  3. [generator.draw() for _ in range(10)] # [1, 3, 2, 3, 2, 3, 3, 2, 2, 1]

对于一对中心词和上下文词,我们随机抽取了K个(实验中为5个)噪声词。根据word2vec论文中的建议,将噪声词w的采样概率P(w)设置为其在字典中的相对频率,其幂为0.75 [Mikolov et al., 2013b]

  1. #@save
  2. def get_negatives(all_contexts, vocab, counter, K):
  3. """返回负采样中的噪声词"""
  4. # 索引为1、2、...(索引0是词表中排除的未知标记)
  5. sampling_weights = [counter[vocab.to_tokens(i)]**0.75
  6. for i in range(1, len(vocab))]
  7. all_negatives, generator = [], RandomGenerator(sampling_weights)
  8. for contexts in all_contexts:
  9. negatives = []
  10. while len(negatives) < len(contexts) * K:
  11. neg = generator.draw()
  12. # 噪声词不能是上下文词
  13. if neg not in contexts:
  14. negatives.append(neg)
  15. all_negatives.append(negatives)
  16. return all_negatives
  17. all_negatives = get_negatives(all_contexts, vocab, counter, 5)

14.3.5. 小批量加载训练实例

在提取所有中心词及其上下文词和采样噪声词后,将它们转换成小批量的样本,在训练过程中可以迭代加载。
在小批量中,14. 自然语言处理:预训练 - 图69个样本包括中心词及其14. 自然语言处理:预训练 - 图70个上下文词和14. 自然语言处理:预训练 - 图71个噪声词。由于上下文窗口大小不同,14. 自然语言处理:预训练 - 图72对于不同的14. 自然语言处理:预训练 - 图73是不同的。因此,对于每个样本,我们在contexts_negatives个变量中将其上下文词和噪声词连结起来,并填充零,直到连结长度达到14. 自然语言处理:预训练 - 图74#card=math&code=%5Ctext%7Bmax%7D%28n%7Bi%7D%2Bm%7Bi%7D%29&id=eKamc)。为了在计算损失时排除填充,定义了掩码变量masksmasks中的元素和contexts_negatives中的元素一一对应,其中masks中的0对应contexts_negatives中的零填充。
为了区分正反例,在contexts_negatives中通过一个labels变量将上下文词与噪声词分开。在labels中的元素和contexts_negatives中的元素之间也存在一一对应关系,其中1对应上下文词正例。
在下面的batchify函数。其输入data是长度等于批量大小的列表,其中每个元素是由中心词center、其上下文词context和其噪声词negative组成的样本。此函数返回一个可以在训练期间加载用于计算的小批量。

  1. #@save
  2. def batchify(data):
  3. """返回带有负采样的跳元模型的小批量样本"""
  4. max_len = max(len(c) + len(n) for _, c, n in data)
  5. centers, contexts_negatives, masks, labels = [], [], [], []
  6. for center, context, negative in data:
  7. cur_len = len(context) + len(negative)
  8. centers += [center]
  9. contexts_negatives += \
  10. [context + negative + [0] * (max_len - cur_len)]
  11. masks += [[1] * cur_len + [0] * (max_len - cur_len)]
  12. labels += [[1] * len(context) + [0] * (max_len - len(context))]
  13. return (torch.tensor(centers).reshape((-1, 1)), torch.tensor(
  14. contexts_negatives), torch.tensor(masks), torch.tensor(labels))

14.3.6. 整合代码

#@save
def load_data_ptb(batch_size, max_window_size, num_noise_words):
    """下载PTB数据集,然后将其加载到内存中"""
    num_workers = d2l.get_dataloader_workers()
    sentences = read_ptb()
    vocab = d2l.Vocab(sentences, min_freq=10)
    subsampled, counter = subsample(sentences, vocab)
    corpus = [vocab[line] for line in subsampled]
    all_centers, all_contexts = get_centers_and_contexts(
        corpus, max_window_size)
    all_negatives = get_negatives(
        all_contexts, vocab, counter, num_noise_words)

    class PTBDataset(torch.utils.data.Dataset):
        def __init__(self, centers, contexts, negatives):
            assert len(centers) == len(contexts) == len(negatives)
            self.centers = centers
            self.contexts = contexts
            self.negatives = negatives

        def __getitem__(self, index):
            return (self.centers[index], self.contexts[index],
                    self.negatives[index])

        def __len__(self):
            return len(self.centers)

    dataset = PTBDataset(all_centers, all_contexts, all_negatives)

    data_iter = torch.utils.data.DataLoader(
        dataset, batch_size, shuffle=True,
        collate_fn=batchify, num_workers=num_workers) # 对应返回的每一个小批量数据进行处理
    return data_iter, vocab

# 打印数据迭代器的第一个小批量。
data_iter, vocab = load_data_ptb(512, 5, 5)
for batch in data_iter:
    for name, data in zip(names, batch):
        print(name, 'shape:', data.shape)
    break
"""
centers shape: torch.Size([512, 1])
contexts_negatives shape: torch.Size([512, 60])
masks shape: torch.Size([512, 60])
labels shape: torch.Size([512, 60])
"""

14.4. 预训练word2vec

继续实现[14.1节]中定义的跳元语法模型。

import math
import torch
from torch import nn
from d2l import torch as d2l

# 获取数据迭代器和词表
batch_size, max_window_size, num_noise_words = 512, 5, 5
data_iter, vocab = d2l.load_data_ptb(batch_size, max_window_size,
                                     num_noise_words)

14.4.1. 跳元模型

如 [9.7节]所述,嵌入层将词元的索引映射到其特征向量。该层权重的行数等于字典大小(input_dim),列数等于每个标记的向量维数(output_dim)。在词嵌入模型训练之后,这个权重就是我们所需要的。
在前向传播中,跳元语法模型的输入包括形状为(批量大小,1)的中心词索引center和形状为(批量大小,max_len)的上下文与噪声词索引contexts_and_negatives。这两个变量首先通过嵌入层从词元索引转换成向量,然后它们的批量矩阵相乘(在 [10.2.4节]中描述)返回形状为(批量大小,1,max_len)的输出。输出中的每个元素是中心词向量和上下文或噪声词向量的点积。

def skip_gram(center, contexts_and_negatives, embed_v, embed_u):
    v = embed_v(center)
    u = embed_u(contexts_and_negatives)
    pred = torch.bmm(v, u.permute(0, 2, 1))
    return pred

14.4.2. 训练

根据[14.2.1节]中负采样损失函数的定义,我们使用如下二元交叉熵损失:

class SigmoidBCELoss(nn.Module):
    # 带掩码的二元交叉熵损失
    def __init__(self):
        super().__init__()

    def forward(self, inputs, target, mask=None):
        out = nn.functional.binary_cross_entropy_with_logits(
            inputs, target, weight=mask, reduction="none")
        return out.mean(dim=1)

loss = SigmoidBCELoss()

我们定义了两个嵌入层,将词表中的所有单词分别作为中心词和上下文词使用。字向量维度embed_size被设置为100。

embed_size = 100
net = nn.Sequential(nn.Embedding(num_embeddings=len(vocab),
                                 embedding_dim=embed_size),
                    nn.Embedding(num_embeddings=len(vocab),
                                 embedding_dim=embed_size))

训练阶段代码实现定义如下。由于填充的存在,损失函数的计算与以前的训练函数略有不同。

def train(net, data_iter, lr, num_epochs, device=d2l.try_gpu()):
    def init_weights(m):
        if type(m) == nn.Embedding:
            nn.init.xavier_uniform_(m.weight)
    net.apply(init_weights)
    net = net.to(device)
    optimizer = torch.optim.Adam(net.parameters(), lr=lr)
    animator = d2l.Animator(xlabel='epoch', ylabel='loss',
                            xlim=[1, num_epochs])
    # 规范化的损失之和,规范化的损失数
    metric = d2l.Accumulator(2)
    for epoch in range(num_epochs):
        timer, num_batches = d2l.Timer(), len(data_iter)
        for i, batch in enumerate(data_iter):
            optimizer.zero_grad()
            center, context_negative, mask, label = [
                data.to(device) for data in batch]

            pred = skip_gram(center, context_negative, net[0], net[1])
            l = (loss(pred.reshape(label.shape).float(), label.float(), mask)
                     / mask.sum(axis=1) * mask.shape[1])
            l.sum().backward()
            optimizer.step()
            metric.add(l.sum(), l.numel())
            if (i + 1) % (num_batches // 5) == 0 or i == num_batches - 1:
                animator.add(epoch + (i + 1) / num_batches,
                             (metric[0] / metric[1],))
    print(f'loss {metric[0] / metric[1]:.3f}, '
          f'{metric[1] / timer.stop():.1f} tokens/sec on {str(device)}')

现在,我们可以使用负采样来训练跳元模型。

lr, num_epochs = 0.002, 5
train(net, data_iter, lr, num_epochs)
# loss 0.410, 402262.0 tokens/sec on cuda:0

output_word2vec-pretraining_d81279_96_1.svg

14.4.3. 应用词嵌入

在训练word2vec模型之后,可以使用训练好模型中词向量的余弦相似度来从词表中找到与输入单词语义最相似的单词。

def get_similar_tokens(query_token, k, embed):
    W = embed.weight.data
    x = W[vocab[query_token]]
    # 计算余弦相似性。增加1e-9以获得数值稳定性
    cos = torch.mv(W, x) / torch.sqrt(torch.sum(W * W, dim=1) *
                                      torch.sum(x * x) + 1e-9)
    topk = torch.topk(cos, k=k+1)[1].cpu().numpy().astype('int32')
    for i in topk[1:]:  # 删除输入词
        print(f'cosine sim={float(cos[i]):.3f}: {vocab.to_tokens(i)}')

get_similar_tokens('chip', 3, net[0])
"""
cosine sim=0.757: microprocessor
cosine sim=0.701: intel
cosine sim=0.611: computer
"""

14.4.4 窗口大小和负样本数

Word2vec预训练时有两个关键的超参数:窗口大小和负样本的数量。

  • 窗口大小:不同的任务适合不同的窗口大小。一种启发式方法是:
    • 使用较小的窗口大小(2-15),两个嵌入之间的高相似性得分表明这些词是可互换的。如果我们只查看附近距离很近的单词,反义词通常可以互换。
    • 使用较大的窗口大小(15-50,甚至更多)会得到相似性更能指示单词相关性的嵌入。例如,好的和坏的经常出现在类似的语境中。实际操作中通常需要对嵌入过程提供指导以帮助读者得到相似的”语感“。Gensim默认窗口大小为5。
  • 负样本数量:原始论文认为5-20个负样本是比较理想的数量。它还指出,当拥有足够大的数据集时,2-5个似乎就已经足够了。Gensim默认为5个负样本。

    14.8. 来自Transformers的双向编码器表示(BERT)

    Word2vec模型中的词与上下文是无关的。在自然语言中,同一个词所表示的含义不仅和其自身有关,还与其所处的上下文有关。流行的上下文敏感表示包括TagLM(language-model-augmented sequence tagger,语言模型增强的序列标记器) [Peters et al., 2017b]、CoVe(Context Vectors,上下文向量) [McCann et al., 2017]和ELMo(Embeddings from Language Models,来自语言模型的嵌入) [Peters et al., 2018]
    在传统的自然语言处理中,需要先构建词向量模型,再使用特定的网络进行处理,但为每一个任务设计一个特定的架构并不是一件容易的事。GPT(Generative Pre Training,生成式预训练)模型为上下文的敏感表示设计了通用的任务无关模型 [Radford et al., 2018]。GPT建立在Transformer解码器的基础上,预训练了一个用于表示文本序列的语言模型。当将GPT应用于下游任务时,仅需附加不同的线性输出层,进行微调训练,以预测任务的标签。然而,GPT不能通过上下文信息很好的理解上下文敏感词的含义,而BERT将这两种方式进行了结合。
    elmo-gpt-bert.svg
    使用时,BERT(Bidirectional Encoder Representations from Transformers)表示将根据不同的任务性质被输入到一个添加的输出层中。其次,对预训练Transformer编码器的所有参数进行微调,而额外的输出层将从头开始训练。
    概念上简单但经验上强大的自然语言深度表示预训练已经彻底改变了各种自然语言处理任务的解决方案。

    14.8.1. 输入表示

    在自然语言处理中,有些任务(如情感分析)以单个文本作为输入,而有些任务(如自然语言推断)以一对文本序列作为输入。BERT输入序列明确表示单个文本和文本对。一个BERT输入序列可以包括一个文本序列或两个文本序列。[CLS]用来标识输入两句话是否有上下文关系,[SEP]是语句分离标识符。序列的长度一般不超过512或1024,不足用[PAD]填充。

    #@save
    def get_tokens_and_segments(tokens_a, tokens_b=None):
     """获取输入序列的词元嵌入及其段嵌入(单句或句子对)"""
     tokens = ['<cls>'] + tokens_a + ['<sep>']
     # 0和1分别标记片段A和B
     segments = [0] * (len(tokens_a) + 2)
     if tokens_b is not None:
         tokens += tokens_b + ['<sep>']
         segments += [1] * (len(tokens_b) + 1)
     return tokens, segments
    

    BERT输入序列的嵌入是词元嵌入、片段嵌入和位置嵌入的和。
    bert-input.svg
    Token Embeddings:将一句话分割为一个个词向量。如果input是英文单词则需要使用Bert附带的Tokenization工具对每一个单词进行分词;如果输入中文仅将每一个字用空格分隔即可。BERT无法处理文本字符,需要通过Bert自带的字典vocab.txt将每一个分词后的分量转化为字典索引id进行输入。
    Segment Embeddings:将多句话进行区分。一般给上句全0的token,下句全1的token。
    Position Embedding:BERT通过训练将位置嵌入学出来,而无需像Transformer通过特定规则来构建。

    #@save
    class BERTEncoder(nn.Module):
     """BERT编码器"""
     def __init__(self, vocab_size, num_hiddens, norm_shape, ffn_num_input,
                  ffn_num_hiddens, num_heads, num_layers, dropout,
                  max_len=1000, key_size=768, query_size=768, value_size=768,
                  **kwargs):
         super(BERTEncoder, self).__init__(**kwargs)
         self.token_embedding = nn.Embedding(vocab_size, num_hiddens)
         self.segment_embedding = nn.Embedding(2, num_hiddens)# 仅为0或1
         # 堆叠Transformer block
         self.blks = nn.Sequential()
         for i in range(num_layers):
             self.blks.add_module(f"{i}", d2l.EncoderBlock(
                 key_size, query_size, value_size, num_hiddens, norm_shape,
                 ffn_num_input, ffn_num_hiddens, num_heads, dropout, True))
         # 位置嵌入是可学习的
         self.pos_embedding = nn.Parameter(torch.randn(1, max_len,
                                                       num_hiddens))
    
     def forward(self, tokens, segments, valid_lens):
         # 在以下代码段中,X的形状保持不变:(批量大小,最大序列长度,num_hiddens)
         # 前向传播中进行了嵌入表示
         X = self.token_embedding(tokens) + self.segment_embedding(segments)
         X = X + self.pos_embedding.data[:, :X.shape[1], :]
         for blk in self.blks:
             X = blk(X, valid_lens)
         return X
    

    14.8.2. 预训练任务

    预训练过程就是生成Bert模型的过程。BERTEncoder的前向推断给出了每个词元的BERT表示。接下来,将使用这些表示来计算预训练BERT的损失函数。

    14.8.2.1. 掩蔽语言模型(Masked Language Modeling)

    为了双向编码上下文以表示每个词元,BERT随机掩蔽词元并使用来自双向上下文的词元以自监督的方式预测掩蔽词元。
    随机选择15%的词元作为预测的掩蔽词元,做Loss时只计算被遮盖的部分

  • 80%为特殊的“[mask]“词元(例如,“this movie is great”变为“this movie is”;

  • 10%为随机词元(例如,“this movie is great”变为“this movie is drink”);
  • 10%内为不变的标签词元(例如,“this movie is great”变为“this movie is great”)。

请注意,在15%的时间中,有10%的时间插入了随机词元。这种噪声鼓励BERT在其双向上下文编码中不那么偏向于掩蔽词元。
MaskLM在前向推断中需要两个输入:BERTEncoder的编码结果和用于每个样本需要预测的词元位置。输出是这些位置的预测结果。

#@save
class MaskLM(nn.Module):
    """掩蔽语言模型"""
    def __init__(self, vocab_size, num_hiddens, num_inputs=768, **kwargs):
        super(MaskLM, self).__init__(**kwargs)
        self.mlp = nn.Sequential(nn.Linear(num_inputs, num_hiddens),
                                 nn.ReLU(),
                                 nn.LayerNorm(num_hiddens),
                                 # 隐藏层到字向量的映射,用于实现预测
                                 nn.Linear(num_hiddens, vocab_size))
    # 在vocab_size(最后一个)维度做softmax归一化就可以通过vocab_size里概率
    # 最大的字来得到模型的预测结果,就可以和准备好的Label做损失(Loss)并反传梯度了。
    def forward(self, X, pred_positions):
        num_pred_positions = pred_positions.shape[1]# 获取每个句子要预测的输入长度
        pred_positions = pred_positions.reshape(-1)
        batch_size = X.shape[0]
        batch_idx = torch.arange(0, batch_size)
        batch_idx = torch.repeat_interleave(batch_idx, num_pred_positions)
        # 假设batch_size=2,num_pred_positions=3
        # 那么batch_idx是([0,0,0,1,1,1])
        masked_X = X[batch_idx, pred_positions]# 提取出相应位置的词元
        masked_X = masked_X.reshape((batch_size, num_pred_positions, -1))
        mlm_Y_hat = self.mlp(masked_X)
        return mlm_Y_hat

通过掩码下的预测词元mlm_Y的真实标签mlm_Y_hat,可以计算遮蔽语言模型任务的交叉熵损失。

mlm_Y = torch.tensor([[7, 8, 9], [10, 20, 30]])
loss = nn.CrossEntropyLoss(reduction='none')
mlm_l = loss(mlm_Y_hat.reshape((-1, vocab_size)), mlm_Y.reshape(-1))
mlm_l.shape # torch.Size([6])

14.8.2.2. 下一句预测(Next Sentence Prediction)

尽管掩蔽语言建模能够编码双向上下文来表示单词,但它不能显式地建模文本对之间的逻辑关系。下一句预测在为预训练生成句子对时,有一半的时间它们确实是为“真”的连续句子;另一半的时间,第二个句子是从语料库中随机抽取的。
由于Transformer编码器中的自注意力,特殊词元“[cls]”的BERT表示已经对输入的两个句子进行了编码。因此,下一句预测讲[cls]的输出输入到全连接层进行预测。即[cls]对应着seq_len维度的第0条,cls_vector = Xhidden[:, 0, :],cls_vector的维度是[batch_size, embedding_dimension]。

#@save
class NextSentencePred(nn.Module):
    """BERT的下一句预测任务"""
    def __init__(self, num_inputs, **kwargs):
        super(NextSentencePred, self).__init__(**kwargs)
        self.output = nn.Linear(num_inputs, 2)

    def forward(self, X):
        # X的形状:(batchsize,num_hiddens)
        return self.output(X)

计算两个二元分类的交叉熵损失。

nsp_y = torch.tensor([0, 1])
nsp_l = loss(nsp_Y_hat, nsp_y)
nsp_l.shape    # torch.Size([2])

上述两个预训练任务中的所有标签都可以从预训练语料库中获得,而无需人工标注。原始的BERT已经在图书语料库 [Zhu et al., 2015]和英文维基百科的连接上进行了预训练。这两个文本语料库非常庞大:它们分别有8亿个单词和25亿个单词。

14.8.3. 整合代码

在预训练BERT时,最终的损失函数是掩蔽语言模型损失函数和下一句预测损失函数的线性组合。前向推断返回编码后的BERT表示encoded_X、掩蔽语言模型预测mlm_Y_hat和下一句预测nsp_Y_hat

#@save
class BERTModel(nn.Module):
    """BERT模型"""
    def __init__(self, vocab_size, num_hiddens, norm_shape, ffn_num_input,
                 ffn_num_hiddens, num_heads, num_layers, dropout,
                 max_len=1000, key_size=768, query_size=768, value_size=768,
                 hid_in_features=768, mlm_in_features=768,
                 nsp_in_features=768):
        super(BERTModel, self).__init__()
        self.encoder = BERTEncoder(vocab_size, num_hiddens, norm_shape,
                    ffn_num_input, ffn_num_hiddens, num_heads, num_layers,
                    dropout, max_len=max_len, key_size=key_size,
                    query_size=query_size, value_size=value_size)
        self.mlm = MaskLM(vocab_size, num_hiddens, mlm_in_features)
        self.hidden = nn.Sequential(nn.Linear(hid_in_features, num_hiddens),nn.Tanh())
        self.nsp = NextSentencePred(nsp_in_features)

    def forward(self, tokens, segments, valid_lens=None,
                pred_positions=None):
        encoded_X = self.encoder(tokens, segments, valid_lens)
        if pred_positions is not None:
            mlm_Y_hat = self.mlm(encoded_X, pred_positions)
        else:
            mlm_Y_hat = None
        # 用于下一句预测的多层感知机分类器的隐藏层,0是“<cls>”标记的索引
        nsp_Y_hat = self.nsp(self.hidden(encoded_X[:, 0, :]))
        return encoded_X, mlm_Y_hat, nsp_Y_hat

14.9. 用于预训练BERT的数据

为了两个预训练的顺利执行,需要以理想的格式生成数据集。最初的BERT模型可能不适合医学等特定领域的应用。因此,在定制的数据集上对BERT进行预训练变得越来越流行。

#@save
d2l.DATA_HUB['wikitext-2'] = (
    'https://s3.amazonaws.com/research.metamind.io/wikitext/'
    'wikitext-2-v1.zip', '3c914d17d80b1459be871a5039ac23e752a53cbe')

#@save
def _read_wiki(data_dir):
    file_name = os.path.join(data_dir, 'wiki.train.tokens')
    with open(file_name, 'r') as f:
        lines = f.readlines()
    # 大写字母转换为小写字母
    paragraphs = [line.strip().lower().split(' . ')
                  for line in lines if len(line.split(' . ')) >= 2]
    random.shuffle(paragraphs)
    return paragraphs

14.9.1. 生成下一句预测任务的数据

#@save
def _get_next_sentence(sentence, next_sentence, paragraphs):#(原句,下一句,语料库)
    if random.random() < 0.5:
        is_next = True
    else:
        # paragraphs是三重列表的嵌套
        next_sentence = random.choice(random.choice(paragraphs))
        is_next = False
    return sentence, next_sentence, is_next

#@save
def _get_nsp_data_from_paragraph(paragraph, paragraphs, vocab, max_len):    # paragraph是句子列表,其中每个句子都是词元列表。max_len指定输入序列的最大长度。
    nsp_data_from_paragraph = []
    for i in range(len(paragraph) - 1):
        tokens_a, tokens_b, is_next = _get_next_sentence(
            paragraph[i], paragraph[i + 1], paragraphs)
        # 考虑1个'<cls>'词元和2个'<sep>'词元
        if len(tokens_a) + len(tokens_b) + 3 > max_len:
            continue
        tokens, segments = d2l.get_tokens_and_segments(tokens_a, tokens_b)
        nsp_data_from_paragraph.append((tokens, segments, is_next))
    return nsp_data_from_paragraph

14.9.2. 生成遮蔽语言模型任务的数据

tokens是输入序列的词元列表,candidate_pred_positions是输入序列的词元索引列表(特殊词元在遮蔽语言模型任务中不被预测),以及num_mlm_preds指示预测的数量(选择15%要预测的随机词元)。该函数返回可能替换后的输入词元、发生预测的词元索引和这些预测的标签。

#@save
def _replace_mlm_tokens(tokens, candidate_pred_positions, num_mlm_preds,
                        vocab):
    # 为遮蔽语言模型的输入创建新的词元副本
    mlm_input_tokens = [token for token in tokens]
    pred_positions_and_labels = []
    random.shuffle(candidate_pred_positions)# 打乱候选词元顺序,每次仅取前面部分
    for mlm_pred_position in candidate_pred_positions:
        if len(pred_positions_and_labels) >= num_mlm_preds:# 控制随机选择的词元数量
            break
        masked_token = None
        # 80%的时间:将词替换为“<mask>”词元
        if random.random() < 0.8:
            masked_token = '<mask>'
        else:
            # 10%的时间:保持词不变
            if random.random() < 0.5:
                masked_token = tokens[mlm_pred_position]
            # 10%的时间:用随机词替换该词
            else:
                masked_token = random.choice(vocab.idx_to_token)
        mlm_input_tokens[mlm_pred_position] = masked_token
        pred_positions_and_labels.append(
            (mlm_pred_position, tokens[mlm_pred_position]))
    return mlm_input_tokens, pred_positions_and_labels

以下函数将BERT输入序列(tokens)作为输入,并返回输入词元的索引、发生预测的词元索引以及这些预测的标签索引。注意,在遮蔽语言模型任务中不会预测[cls]和[sep]特殊词元。

#@save
def _get_mlm_data_from_tokens(tokens, vocab):
    candidate_pred_positions = []
    # tokens是一个字符串列表
    for i, token in enumerate(tokens):
        if token in ['<cls>', '<sep>']:
            continue
        candidate_pred_positions.append(i)
    # 遮蔽语言模型任务中预测15%的随机词元
    num_mlm_preds = max(1, round(len(tokens) * 0.15))
    mlm_input_tokens, pred_positions_and_labels = _replace_mlm_tokens(
        tokens, candidate_pred_positions, num_mlm_preds, vocab)
    pred_positions_and_labels = sorted(pred_positions_and_labels,
                                       key=lambda x: x[0])
    pred_positions = [v[0] for v in pred_positions_and_labels]
    mlm_pred_labels = [v[1] for v in pred_positions_and_labels]
    return vocab[mlm_input_tokens], pred_positions, vocab[mlm_pred_labels]

14.9.3. 将文本转换为预训练数据集

函数_pad_bert_inputs将特殊的[mask]词元附加到输入。参数examples包含来自函数_get_nsp_data_from_paragraph_get_mlm_data_from_tokens的输出。

#@save
def _pad_bert_inputs(examples, max_len, vocab):
    max_num_mlm_preds = round(max_len * 0.15)
    all_token_ids, all_segments, valid_lens,  = [], [], []
    all_pred_positions, all_mlm_weights, all_mlm_labels = [], [], []
    nsp_labels = []
    for (token_ids, pred_positions, mlm_pred_label_ids, segments,
         is_next) in examples:
        all_token_ids.append(torch.tensor(token_ids + [vocab['<pad>']] * (
            max_len - len(token_ids)), dtype=torch.long))
        all_segments.append(torch.tensor(segments + [0] * (
            max_len - len(segments)), dtype=torch.long))
        # valid_lens不包括'<pad>'的计数
        valid_lens.append(torch.tensor(len(token_ids), dtype=torch.float32))
        all_pred_positions.append(torch.tensor(pred_positions + [0] * (
            max_num_mlm_preds - len(pred_positions)), dtype=torch.long))
        # 填充词元的预测将通过乘以0权重在损失中过滤掉
        all_mlm_weights.append(
            torch.tensor([1.0] * len(mlm_pred_label_ids) + [0.0] * (
                max_num_mlm_preds - len(pred_positions)),
                dtype=torch.float32))
        all_mlm_labels.append(torch.tensor(mlm_pred_label_ids + [0] * (
            max_num_mlm_preds - len(mlm_pred_label_ids)), dtype=torch.long))
        nsp_labels.append(torch.tensor(is_next, dtype=torch.long))
    return (all_token_ids, all_segments, valid_lens, all_pred_positions,
            all_mlm_weights, all_mlm_labels, nsp_labels)

定义_WikiTextDataset类为用于预训练BERT的WikiText-2数据集。通过实现__getitem__函数,可以任意访问WikiText-2语料库的一对句子生成的预训练样本(遮蔽语言模型和下一句预测)样本。为简单起见,使用d2l.tokenize函数进行词元化。出现次数少于5次的不频繁词元将被过滤掉。

#@save
class _WikiTextDataset(torch.utils.data.Dataset):
    def __init__(self, paragraphs, max_len):
        # 输入paragraphs[i]是代表段落的句子字符串列表;
        # 而输出paragraphs[i]是代表段落的句子列表,其中每个句子都是词元列表
        paragraphs = [d2l.tokenize(
            paragraph, token='word') for paragraph in paragraphs]
        sentences = [sentence for paragraph in paragraphs    # 词列表
                     for sentence in paragraph]
        # 转化为索引,并过滤出现次数小于5的词
        self.vocab = d2l.Vocab(sentences, min_freq=5, reserved_tokens=[
            '<pad>', '<mask>', '<cls>', '<sep>'])
        # 获取下一句子预测任务的数据
        examples = []
        for paragraph in paragraphs:# pargraphs为三重列表,总体⼀段落⼀句⼦列表
            examples.extend(_get_nsp_data_from_paragraph(
                paragraph, paragraphs, self.vocab, max_len))
        # 获取遮蔽语言模型任务的数据
        examples = [(_get_mlm_data_from_tokens(tokens, self.vocab)
                      + (segments, is_next))
                     for tokens, segments, is_next in examples]
        # 填充输入
        (self.all_token_ids, self.all_segments, self.valid_lens,
         self.all_pred_positions, self.all_mlm_weights,
         self.all_mlm_labels, self.nsp_labels) = _pad_bert_inputs(
            examples, max_len, self.vocab)

    def __getitem__(self, idx):
        return (self.all_token_ids[idx], self.all_segments[idx],
                self.valid_lens[idx], self.all_pred_positions[idx],
                self.all_mlm_weights[idx], self.all_mlm_labels[idx],
                self.nsp_labels[idx])

    def __len__(self):
        return len(self.all_token_ids)

通过使用_read_wiki函数和_WikiTextDataset类,我们定义了下面的load_data_wiki来下载并生成WikiText-2数据集,并从中生成预训练样本。

#@save
def load_data_wiki(batch_size, max_len):
    """加载WikiText-2数据集"""
    num_workers = d2l.get_dataloader_workers()
    data_dir = d2l.download_extract('wikitext-2', 'wikitext-2')
    paragraphs = _read_wiki(data_dir)
    train_set = _WikiTextDataset(paragraphs, max_len)
    train_iter = torch.utils.data.DataLoader(train_set, batch_size,
                                shuffle=True,num_workers=num_workers)
    return train_iter, train_set.vocab

14.10. 预训练BERT

14.10.1. 预训练BERT

# 数据加载
batch_size, max_len = 512, 64    # 原始BERT模型的最大长度为512
train_iter, vocab = d2l.load_data_wiki(batch_size, max_len)

原始BERT [Devlin et al., 2018]有两个不同版本。14. 自然语言处理:预训练 - 图78使用12层(Transformer编码器块),768个隐藏单元和12个自注意头。14. 自然语言处理:预训练 - 图79使用24层,1024个隐藏单元和16个自注意头。前者有1.1亿个参数,后者有3.4亿个参数。为了便于演示,我们定义了一个小的BERT,使用了2层、128个隐藏单元和2个自注意头。

net = d2l.BERTModel(len(vocab), num_hiddens=128, norm_shape=[128],
                    ffn_num_input=128, ffn_num_hiddens=256, num_heads=2,
                    num_layers=2, dropout=0.2, key_size=128, query_size=128,
                    value_size=128, hid_in_features=128, mlm_in_features=128,
                    nsp_in_features=128)
devices = d2l.try_all_gpus()
loss = nn.CrossEntropyLoss()

函数_get_batch_loss_bert计算遮蔽语言模型和下一句子预测任务的损失。BERT预训练的最终损失是遮蔽语言模型损失和下一句预测损失的和。

#@save
def _get_batch_loss_bert(net, loss, vocab_size, tokens_X,
                         segments_X, valid_lens_x,
                         pred_positions_X, mlm_weights_X,
                         mlm_Y, nsp_y):
    # 前向传播
    _, mlm_Y_hat, nsp_Y_hat = net(tokens_X, segments_X,
                                  valid_lens_x.reshape(-1),
                                  pred_positions_X)
    # 计算遮蔽语言模型损失
    mlm_l = loss(mlm_Y_hat.reshape(-1, vocab_size), mlm_Y.reshape(-1)) *\
    mlm_weights_X.reshape(-1, 1)
    mlm_l = mlm_l.sum() / (mlm_weights_X.sum() + 1e-8)
    # 计算下一句子预测任务的损失
    nsp_l = loss(nsp_Y_hat, nsp_y)
    l = mlm_l + nsp_l
    return mlm_l, nsp_l, l

train_bert定义了在WikiText-2(train_iter)数据集上预训练BERT(net)的过程。以下函数的输入num_steps指定了训练的batch步数,而不是像train_ch13函数指定训练的轮数。

def train_bert(train_iter, net, loss, vocab_size, devices, num_steps):
    net = nn.DataParallel(net, device_ids=devices).to(devices[0])# 使用多个GPU处理
    trainer = torch.optim.Adam(net.parameters(), lr=0.01)
    step, timer = 0, d2l.Timer()
    animator = d2l.Animator(xlabel='step', ylabel='loss',
                            xlim=[1, num_steps], legend=['mlm', 'nsp'])
    # 遮蔽语言模型损失的和,下一句预测任务损失的和,句子对的数量,计数
    metric = d2l.Accumulator(4)
    num_steps_reached = False
    while step < num_steps and not num_steps_reached:
        for tokens_X, segments_X, valid_lens_x, pred_positions_X,\
            mlm_weights_X, mlm_Y, nsp_y in train_iter:
            tokens_X = tokens_X.to(devices[0])
            segments_X = segments_X.to(devices[0])
            valid_lens_x = valid_lens_x.to(devices[0])
            pred_positions_X = pred_positions_X.to(devices[0])
            mlm_weights_X = mlm_weights_X.to(devices[0])
            mlm_Y, nsp_y = mlm_Y.to(devices[0]), nsp_y.to(devices[0])
            trainer.zero_grad()
            timer.start()
            mlm_l, nsp_l, l = _get_batch_loss_bert(
                net, loss, vocab_size, tokens_X, segments_X, valid_lens_x,
                pred_positions_X, mlm_weights_X, mlm_Y, nsp_y)
            l.backward()
            trainer.step()
            metric.add(mlm_l, nsp_l, tokens_X.shape[0], 1)
            timer.stop()
            animator.add(step + 1,
                         (metric[0] / metric[3], metric[1] / metric[3]))
            step += 1
            if step == num_steps:
                num_steps_reached = True
                break

    print(f'MLM loss {metric[0] / metric[3]:.3f}, '
          f'NSP loss {metric[1] / metric[3]:.3f}')
    print(f'{metric[2] / timer.sum():.1f} sentence pairs/sec on '
          f'{str(devices)}')

train_bert(train_iter, net, loss, len(vocab), devices, 50)
"""
MLM loss 5.639, NSP loss 0.758
2955.6 sentence pairs/sec on [device(type='cuda', index=0), device(type='cuda', index=1)]
"""

output_bert-pretraining_41429c_51_1.svg

14.10.2. 用BERT表示文本

预训练BERT之后,可以用它来表示词元。下面的函数返回tokens_atokens_b中所有词元的BERT(net)表示。

def get_bert_encoding(net, tokens_a, tokens_b=None):
    tokens, segments = d2l.get_tokens_and_segments(tokens_a, tokens_b)
    token_ids = torch.tensor(vocab[tokens], device=devices[0]).unsqueeze(0)
    segments = torch.tensor(segments, device=devices[0]).unsqueeze(0)
    valid_len = torch.tensor(len(tokens), device=devices[0]).unsqueeze(0)
    encoded_X, _, _ = net(token_ids, segments, valid_len)
    return encoded_X

考虑“a crane is flying”这句话。插入特殊标记[cls](用于分类)和[sep](用于分隔)后,BERT输入序列的长度为6。因为零是[cls]词元,encoded_text[:, 0, :]是整个输入语句的BERT表示。为了评估一词多义词元“crane”,我们还打印出了该词元的BERT表示的前三个元素。

tokens_a = ['a', 'crane', 'is', 'flying']
encoded_text = get_bert_encoding(net, tokens_a)
# 词元:'<cls>','a','crane','is','flying','<sep>'
encoded_text_cls = encoded_text[:, 0, :]
encoded_text_crane = encoded_text[:, 2, :]
encoded_text.shape, encoded_text_cls.shape, encoded_text_crane[0][:3]
"""
(torch.Size([1, 6, 128]),
 torch.Size([1, 128]),
 tensor([-0.0409,  1.4296,  1.0378], device='cuda:0', grad_fn=<SliceBackward>))
"""

现在考虑一个句子对“a crane driver came”和“he just left”。类似地,encoded_pair[:, 0, :]是来自BERT对整个句子对的编码结果。注意,多义词元“crane”的前三个元素与上下文不同时的元素不同。这说明了BERT表示是上下文敏感的。

tokens_a, tokens_b = ['a', 'crane', 'driver', 'came'], ['he', 'just', 'left']
encoded_pair = get_bert_encoding(net, tokens_a, tokens_b)
# 词元:'<cls>','a','crane','driver','came','<sep>','he','just',
# 'left','<sep>'
encoded_pair_cls = encoded_pair[:, 0, :]
encoded_pair_crane = encoded_pair[:, 2, :]
encoded_pair.shape, encoded_pair_cls.shape, encoded_pair_crane[0][:3]
"""
(torch.Size([1, 10, 128]),
 torch.Size([1, 128]),
 tensor([-0.0598,  1.4598,  1.0518], device='cuda:0', grad_fn=<SliceBackward>)
"""

14.11 BERT的使用

BERT的每个输入词元都会对应一个固定长度的输出向量,由于使用了 Transformer结构,每个输出 向量都会包含整个上下文信息。

  • 句子级分类:将[cls]对应的向量输入到全连接层分类。使用[cls]是因为在预训练时判断两个句子是否为句子时,已经潜在的告诉了BERT,[cls]是用于句子级别的分类向量。当然,也可换为其它词元。

image-20220206193034555.png

  • 命名实体识别(词级分类):识别一个词是不是命名实体,如人名、机构、位置。将非[cls]、[sep]等特殊词元放进全连接层分类。

image-20220206193241778.png

  • 机器问答:给定一个问题和描述文字,找出一个片段作为回答(在文章中包含)。对片段中的每个词预测它是不是回答的开头和结束。

image-20220206193523793.png
即使下游任务各有不同,使用Bert微调时仅需要增加输出层。但根据任务的不同,全连接层的表示不同,所需BERT的输出也不同。