0%

什么是 bert

书接上文,因为参加 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

  1. 在这个类的初始化阶段,首先初始化 $W_Q,W_K,W_V$ 三个全连接层,输入维度和输出维度保持一致。假设输入维度是 512,有 8 个头;
  2. 计算 $Q,K,V$,大小是 B, L, 8, 64,毕竟有 8 个头。在 softmax 之后经过 0.1 的 dropout,最后在把这 8 个头 view 到一起,加上最开始的 $Q$
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
class MultiHeadAttention(nn.Module):
''' Multi-Head Attention module '''

def __init__(self, n_head, d_model, d_k, d_v, dropout=0.1):
super().__init__()

self.n_head = n_head
self.d_k = d_k
self.d_v = d_v

# 输出维度是之前的 8 倍,也就是 8 个头
self.w_qs = nn.Linear(d_model, n_head * d_k, bias=False)
self.w_ks = nn.Linear(d_model, n_head * d_k, bias=False)
self.w_vs = nn.Linear(d_model, n_head * d_v, bias=False)
# 最后的输出维度保持不变
self.fc = nn.Linear(n_head * d_v, d_model, bias=False)

self.attention = ScaledDotProductAttention(temperature=d_k ** 0.5)

self.dropout = nn.Dropout(dropout)
self.layer_norm = nn.LayerNorm(d_model, eps=1e-6)


def forward(self, q, k, v, mask=None):

# 单个 k v 的输出维度,头的数量
d_k, d_v, n_head = self.d_k, self.d_v, self.n_head
sz_b, len_q, len_k, len_v = q.size(0), q.size(1), k.size(1), v.size(1)

residual = q

# batch x len_q x (n_head * dv) -> batch x len_q x n_head x dv
q = self.w_qs(q).view(sz_b, len_q, n_head, d_k)
k = self.w_ks(k).view(sz_b, len_k, n_head, d_k)
v = self.w_vs(v).view(sz_b, len_v, n_head, d_v)

# Transpose for attention dot product: b x n x lq x dv
q, k, v = q.transpose(1, 2), k.transpose(1, 2), v.transpose(1, 2)

if mask is not None:
mask = mask.unsqueeze(1) # For head axis broadcasting.

# mask to pad zero
# q \cdot k and matmul v
q, attn = self.attention(q, k, v, mask=mask)

# Transpose to move the head dimension back: b x lq x n x dv
# Combine the last two dimensions to concatenate all the heads together: b x lq x (n*dv)
q = q.transpose(1, 2).contiguous().view(sz_b, len_q, -1)
q = self.dropout(self.fc(q))
q += residual

q = self.layer_norm(q)

return q, attn

PositionalEncoding

这个就是生成一个 $200\times dim$ 的表,每次输入一个 $x$,查看 $x$ 的维度,从表中取到对应维度的数值,和 $A$ 直接相加。这个表采用的是 sin-cos 规则,使用了 sin 和 cos 函数的线性变换来提供给模型位置信息。

如上图所示,随着维度越来越大,周期变化会越来越慢,而产生一种包含位置信息的纹理。

Encoder Layer

  1. 输入: $x$ 经过 embedding layer 层后得到一个表示,并加上 position embedding 表示向下传播
  2. 经过 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

class BertPooler(nn.Module):
def __init__(self, config):
super().__init__()
self.dense = nn.Linear(config.hidden_size, config.hidden_size)
self.activation = nn.Tanh()
self.config = config

def forward(self, hidden_states):
"""
:param hidden_states: [src_len, batch_size, hidden_size]
:return: [batch_size, hidden_size]
"""
if self.config.pooler_type == "first_token_transform":
token_tensor = hidden_states[0, :].reshape(-1, self.config.hidden_size)
elif self.config.pooler_type == "all_token_average":
token_tensor = torch.mean(hidden_states, dim=0)
pooled_output = self.dense(token_tensor) # [batch_size, hidden_size]
pooled_output = self.activation(pooled_output)
return pooled_output

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 来防止预测过程中看到后面的词汇。

参考

  1. transformer程序
  2. 一个不错的transformer博客
  3. 月来客栈解析的 bert 源码
  4. mask 机制
感谢上学期间打赏我的朋友们。赛博乞讨:我,秦始皇,打钱。

欢迎订阅我的文章