书接上文,因为参加 NLP 的比赛不知道什么是 bert 实在有点说不过去,于是花了三天时间看了下 bert 的基本概念和代码。不得不说,网上阳间的 bert 预训练代码太少了,大多是转载,mark 甚至文不对题之类没啥用的东西,但占据了搜索引擎的首页的热度,像无耻的营销号一样。
从 transformer 开始
这个就不得不提一下 Attention is all your need. 由于处理序列的时候 RNN 不容易并行化,输出 $b_4$ 的时候需要输入 $a_1, a_2, a_3, a_4$。所以就象使用 CNN 来代替 RNN,一个 CNN 按顺序划过序列输入产生一个输出,那么就不用看完一个句子才会有输出,因为先算后面和先算前面得到的结果是一样的,这样就可以并行化。而多个 CNN 就可以产生多个输出,能提升模型的表达能力。如下图所示,同颜色之前可以并行化,不同颜色之间也可以并行化。不需要等待红色的输出算完,再算黄色的输出。
如果考虑让一个 CNN 看到更多的输入,那么只需要在模型的隐层叠加另外的 CNN 即可,也就是上图的蓝色三角。基于这个概念,就有了后面的 self-attention。
self-attention
attention 本质上是一些矩阵乘法,$A=Wx, Q=W_qA, K=W_kA, V=W_vA$,这里其实就是乘以一个大矩阵,只不过图里分开写清楚一些。
之后每个 $q^i$ 和每个 $k^i$ 做 attention,也就是内积,得到如下的 $\alpha$ 输出,然后再除以维度数,防止维度过高导致的内积过大。
然后将 $\alpha$ 经过 softmax 操作得到 $\hat{\alpha}$:
对于 $b_1$ 输出,只需要让 $\hat{\alpha_{1,i}}$ 和所有的 $v_i$ 做乘积并求和即可。同理,可以得到 $b_2,b_3,b_4$ 的输出。
对应的,下图左上角就是我们的 self-attention 层,其中的运算可以总结成矩阵乘法:
multi-head-self-attention
多注意力头机制,可以在计算 $Q,K,V$ 的时候产生多个结果,然后在输出 $b$ 的时候再通过一个矩阵将多个结果融合成一个。而我看的程序,就是将矩阵 $Q,K,V$ 分开,计算完最后 view 到一起。
Positional Encoding
现在的 self-attention 没有考虑到序列的位置信息,而是使用全局信息,不能利用单词的顺序信息,而这部分信息对于 NLP 来说非常重要,所以需要加入位置的 embedding。人工设定每一个位置的 embedding,和 $A$ 加在一起作为新的 $A$ 参与后面的运算,等价于在 $X$ 拼接一个 one-hot 向量后再做运算:
程序
网上最常见的图就是它了,在看完理论后还是由些许的疑惑之处,比如位置编码如何实现,比如注意力机制具体如何执行,只能看代码来解决。
MultiHeadAttention
- 在这个类的初始化阶段,首先初始化 $W_Q,W_K,W_V$ 三个全连接层,输入维度和输出维度保持一致。假设输入维度是 512,有 8 个头;
- 计算 $Q,K,V$,大小是
B, L, 8, 64
,毕竟有 8 个头。在 softmax 之后经过 0.1 的 dropout,最后在把这 8 个头 view 到一起,加上最开始的 $Q$
1 | class MultiHeadAttention(nn.Module): |
PositionalEncoding
这个就是生成一个 $200\times dim$ 的表,每次输入一个 $x$,查看 $x$ 的维度,从表中取到对应维度的数值,和 $A$ 直接相加。这个表采用的是 sin-cos 规则,使用了 sin 和 cos 函数的线性变换来提供给模型位置信息。
如上图所示,随着维度越来越大,周期变化会越来越慢,而产生一种包含位置信息的纹理。
Encoder Layer
- 输入: $x$ 经过 embedding layer 层后得到一个表示,并加上 position embedding 表示向下传播
- 经过 dropout 后进入堆叠 6 层的 encoder layer,单个 encode layer 由 multiheadattention 和 PositionwiseFeedForward 组成,后者是简单的全连接与残差结构。
Decoder Layer
大部分内容和 Encoder Layer 一样,先将 target 经过 embedding layer 之后得到表示,并叠加位置的 embedding 表示向后传播。
首先计算 target 的自注意力,也就是当前翻译和已经翻译的前文之间的关系;而后将输出视为 $Q$,encoder 的输出视为 $K,V$,再次计算注意力,得到新的解码输出,也就是计算一下当前输出结果和编码的特征向量之间的关系。在拿到 decoder 的输出后,经过一个全连接层,将 dim 映射到 n_vocab。
不过这里需要注意的事,为了防止 see the future,decoder 计算 target 的自注意力时需要添加 target mask,就是对角线及其以上的元素都是 0,防止预测当前元素时看到后面的元素。具体来说,真实的句子输入 decoder,经过 embedding 和 position embedding,在自己和自己的注意力矩阵中,对角线及以上的元素全部为 -inf,这样 softmax 之后对角线以上的元素为 0。那么在预测第一个单词时,只能根据 encoder output 的输出,而在预测之后的单词时,可以根据目前预测的结果预测之后的单词。
bert 程序
而 bert 的结构就是 transformer 的多个 Encoder 双向堆叠到一起:
其输入的 embedding 为:
- Token Embeddings 是词向量,第一个单词是CLS标志,可以用于之后的分类任务,通过 embedding 层实现。如果句子很短,pad 为 0。程序传入的参数是 input_dis,输入到 embedding 层中。
- Segment Embeddings 用来区别两种句子,因为预训练还要做 NSP 任务,同样是 embedding 层。程序传入的参数是
token_type_ids
,输入到 embedding 层中。 - Position Embeddings 和之前的 Transformer 不一样,不是三角函数而是学习出来的,非人工设定,而是 embedding 层。输入到 embedding 层中。
将这三个不同的 embedding 层的输出相加之和作为 encoder 输入,经过 layernorm 和 dropout 后输出。大概理论就是这些,不过它的预训练是真的靠谱,或者说,应用到具体任务,可以针对具体任务设计与训练。借着预训练,解释一下上面的符号,也是困扰我很久的东西。
之前一直不知道 CLS 这种东西是干什么的,直到看了代码才清楚,这个符号输入网络,bert encoder 输出的所有状态第一个位置的均值或最后一层的第一个位置的输出,经过全连接和激活,得到的输出,所以这个符号对应位置的输出能用于下游分类任务。
1 |
|
MASK
bert 有效的原因取决于它的预训练,比如 MLM(Mask language model) 和 NSP (Next sentence prediction),而这其中依赖的主要是 mask。
处理非定长序列
在NLP中,文本一般是不定长的,所以在进行 batch训练之前,要先进行长度的统一,过长的句子可以通过truncating 截断到固定的长度,过短的句子可以通过 padding 增加到固定的长度,但是 padding 对应的字符只是为了统一长度,并没有实际的价值,因此希望在之后的计算中屏蔽它们,这时候就需要 Mask。此外,self-attention中,$Q$ 和 $K$ 在点积之后,需要先经过 mask 再进行 softmax,因此,对于要屏蔽的部分,mask之后的输出需要为负无穷,这样softmax之后输出才为0。
辅助预训练
做 MLM 预训练时,需要对句子进行 mask,使得模型看不到输入句子的单词。而后,其 label 为被 mask 掉单词的 id。由于 bert 本身的结构,由于预训练的时候,需要做 NSP 和 MLM,而 NSP 是二分类任务,MLM 是多分类任务,因此需要在 bert 上插入两个头分别实现这两个功能,前者就是将缺失的词汇预测回去,后者加入一个全连接输入 pooler output,判断句子是否为上下文。
防止 see the future
这个已经在前文说过了,因为循环神经网络是时间驱动的,只有当时刻 $t$ 运算结束了,才能看到 $t+1$ 时刻的词。而 Transformer Decoder 抛弃了 RNN,改为 Self-Attention,由此就产生了一个问题,在训练过程中,整个 ground truth 都暴露在 Decoder 中,这显然是不对的。因此需要加入 mask 来防止预测过程中看到后面的词汇。