首页 > 其他分享 >【BERT】详解BERT

【BERT】详解BERT

时间:2024-06-15 11:54:45浏览次数:27  
标签:BERT 模型 ids token 详解 embedding theta

Bert

一、为什么要提出BERT?

传统的RNN类模型,包括LSTM,GRU以及其他各种变体,最大的问题在于提取能力不足。在《Why Self-Attention?
A Targeted Evaluation of Neural Machine Translation Architectures》中证明了RNN的长距离特征提取能力甚至不亚于Transformer,并且比CNN强。其主要问题在于这一类模型的并行能力较差,因为time step的存在,导致每一个时刻的输入必须跟在上一个时刻之后,从而无法使用矩阵进行并行输入。另一方面,ELMo和GPT的提出,正式宣告了迁移学习(预训练+微调)的思想在NLP的引入,并且二者作为动态词向量,逐步代替Word2Vec等静态词向量,解决了“一词多义”的问题。那么,BERT又为何要被提出呢?

如下图所示,BERT,GPT和ELMo的结构图如下。
BERT、GPT和ELMo

从特征提取器方面来看,ELMo使用的是LSTM,而GPT和BERT用的都是Transformer,只不过前者是用decoder而后者用的是encoder。ELMo使用的LSTM提取语义特征的能力不如Transformer。因此在特征提取方面,GPT和BERT都要更好。

从单双向方面来看,GPT是单向的,剩下二者是双向的。显然,GPT只利用了上文的信息去预测某一个词,效果自然比不过BERT这种利用上下文信息来"完形填空"的做法。另外,ELMo本质上也不能算作真正的利用到了双向的信息,因为它两个模块是分开训练的,即图上显示的这种分别由左向LSTM和右向LSTM来提取特征的方式,并且最终使用拼接(concatenate)的融合方式,效果是不如self-attention的特征融合方式的。在原文中,作者称BERT是"deep bi-directional"。

综上所述,我们可以看出BERT是融合了ELMo和GPT两位"大前辈"的优点而改良得到的。BERT的提出,也轰动了NLP界。

二、BERT是什么?

1. 简介

BERT,全称Bidirectional Encoder Representation of Transformer,首次提出于《BERT: Pre-training of Deep Bidirectional Transformers for Language Understanding》一文中。简单来说,BERT是使用了Transformer的encoder(即解码器)部分,因此也可以认为BERT就是Transformer的encoder部分。BERT既可以认为是一个生成Word Embedding的方法,也可以认为是像LSTM这样用于特征提取的模型结构

2. 结构

BERT结构

BERT的结构如上图所示。可以看到当Embeddings被输入后,会经过多层的Transformer的encoder(即图中的Trm)进行特征提取。**注意!!!这里每一层的所有Trm是共用一套\(W_q\)\(,\)W_k\(,\)W_v\(的**,而由于使用了多头注意力机制(Multi-head attention),每一层其实是有多套\)W_q\(,\)W_k\(,\)W_v$的。

论文中提出的BERT分为\(BERT_{BASE}\)和\(BERT_{LARGE}\)。
\(BERT_{BASE}: L = 12, H = 768, A = 12, Total\_Parameters = 110M\)
\(BERT_{LARGE}: L = 24, H = 1024, A = 16, Total\_Parameters = 340M\)
其中,\(L\)代表层数,\(H\)代表Hidden size, \(A\)代表多头注意力的头数。\(BERT_{BASE}\)是为了与GPT对比而提出的,而\(BERT_{LARGE}\)的表现则更优于前者。

1)输入与嵌入


与其他用于NLP任务的模型类似,文本经过分词(tokenization)后,每一个token会在embedding层转化为word embedding,随后再进入模型内部进行后续操作。略微有些不同的是,Bert的输入进入embedding层被分为了三个部分。

Token Embedding

与其他用于NLP问题的模型类似,每个token需要转化为word embedding(词嵌入,亦称word vector词向量),这种结构化的数据才适合作为模型的输入。token embedding的初始化有两种方式。第一种是在预训练时,会生成一个随机初始化的token embedding矩阵。第二种则是更为常见的在预训练模型上微调(fine-tune),在这种情况下就会读取预训练模型预先训练好的embedding矩阵(亦称look-up table),并且在训练过程中进行微调。注意!token embedding的大小是21128*768(中文),30522*768(英文),其中21128和30522分别为中英文vocab的大小,768是word embedding的维度大小。由于模型结构中用到了multi-head self attention机制,使得token embeddings在训练过程中可以学习到上下文信息并以此更新,从而解决一词多义的问题,这也就是BERT被称作动态词向量的原因。在PyTorch中,一般是在定义模型的时候添加这么一句,embedding层中的权重就会跟着更新了。

for param in self.bert.parameters():
      param.requires_grad = True

举例:

值得注意的是,BERT中使用的分词方式是基于WordPiece方法的,并且会添加上\([CLS]\)和\([SEP]\)两个字符。

  1. \([CLS]\)就是classification的意思,一般是放在第一个句子的首位。最后一层的\([CLS]\)字符对应的向量可以作为整句话的语义表示,也就是句向量,从而用于下游的分类任务。使用这个字符是因为与文本中已有的其它词相比,这个无明显语义信息的符号会更“公平”地融合文本中各个词的语义信息,从而更好的表示整句话的语义。

    具体来说,self-attention是用文本中的其它词来增强目标词的语义表示,但是目标词本身的语义还是会占主要部分的,因此,经过BERT的12层,每次词的embedding融合了所有词的信息,可以去更好的表示自己的语义。而\([CLS]\)本身没有语义,经过12层,得到的是attention后所有词的加权平均,相比其他正常词,可以更好的表征句子语义。
    在Hugging Face中是用pooler_output来返回\([CLS]\)的embedding的。官方描述如下:

    this returns the classification token after processing through a linear layer and a tanh activation function. The linear layer weights are trained from the next sentence prediction (classification) objective during pretraining.

    源码中,就是将\([CLS]\)的embedding输入一个fc层和一个tanh函数再输出。

  2. \([SEP]\)就是用于输入为句子对时区分两个句子的字符。

  3. 关于分词。BERT采用的是WordPiece方法,属于subword level的分词方式,介于word和character两个粒度级别之间。这种级别主要是为了解决word级别存在的问题:

  • vocabulary过大
  • 通常会存在out of vocabulary(OOV)的问题
  • vocabulary中会存在很多相似的词
    以及character级别中的问题:
  • 文本序列可能会非常长
  • 无法很好对词语的语义进行表征,毕竟单词都被划分为字母了
    subword是指对相对低频或者很复杂的词语进行拆分,而对于常见的词语例如"dog"是不会拆分的,而相对较为低频的"dogs"则会拆分。这样做可以使得低频词转化为高频词存储在vocabulary中,从而解决了OOV的问题。同时,转化为常见词以后也可以大大降低vocabulary的大小。例如,只需要存放"boy"、"girl"和"##s"就能够表示"boy"、"girl"、"boys"和"girls"这四个词。关于WordPiece算法的具体实现,可以参考理解tokenizer之WordPiece: Subword-based tokenization algorithm
Segment Embedding

BERT可以用于处理句子对输入的分类问题,简单来说就是判断输入的句子对是否语义相似。而往往我们会将两个句子拼接成一个句子对输入至模型中,segment embedding的作用就是用于标识两个不同的句子。举例如下:

事实上,当用BERT处理非句子对输入的任务,例如文本分类时,只需要将输入文本包括padding(补长)部分全部设为0即可。segment embedding矩阵的大小是2*768。

Position Embedding

跟Transformer类似,多头注意力机制的使用会使得文本输入后丢失位置信息,也就是词序。然而词序对于理解一句话来说是非常重要的,“我爱你”和”你爱我”完全是两种意思。因此position embeddings就是用于标识token的位置,而与Transformer中的不同,BERT中的position embeddings的初始化方式和更新方式与token embedding类似,并且采用的是绝对位置。position embedding矩阵的大小是512*768,因为BERT允许的默认最大长度是512

Attention masks

事实上,除了以上embeddings之外,在Hugging Face中还有一个参数是需要我们提供的,就是attention mask。关于这个参数,Hugging Face官方文档的解释是

This argument indicates to the model which tokens should be attended to, and which should not.

由于输入是转化成一个个batch的,因此需要靠补长和截断来保持文本长度的统一,而补长部分是不需要参与attention操作的。1代表需要参与attention的token,而0表示补长的部分

代码实例
text = ['今天天气很好','我觉得很不错这款B48发动机很不错']
for txt in text:
  encoding_result = tokenizer.encode_plus(txt, max_length=10, padding='max_length', truncation = True)
print(encoding_result)
[{'input_ids': [101, 791, 1921, 1921, 3698, 2523, 1962, 102, 0, 0], 'token_type_ids': [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 'attention_mask': [1, 1, 1, 1, 1, 1, 1, 1, 0, 0]}, 
{'input_ids': [101, 2769, 6230, 2533, 2523, 679, 7231, 6821, 3621, 102], 'token_type_ids': [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 'attention_mask': [1, 1, 1, 1, 1, 1, 1, 1, 1, 1]}]

上述例子展示的是两个长短不一致的文本经过tokenizer转换后得到的结果。input_ids是指每个token在vocab中的序号,用这个序号在token embedding矩阵中去查找对应的词嵌入。本质上就是将序号转化为one-hot vector,然后再与embedding矩阵相乘,从而得到矩阵中的某一行/列,这个行/列向量即为所求,这种操作就是look up,这种embedding矩阵也称为look-up table。类似的,token_type_ids则是用于查找segment embedding的,而attention_mask就只是用于标识是否需要attention操作,不会转化为向量。那么position_ids呢?它则是由模型自动生成的,会在模型的forward()函数中生成。Hugging Face官方文档是这样描述的:

position_ids — Indices of positions of each input sequence tokens in the position embeddings. Selected in the range [0, config.max_position_embeddings - 1].

此处的config.max_position_embeddings默认为512,也可以调成1024或者2048。

总结

BERT的输入包含三种embedding:token embedding、segment embedding和position embedding,都是由对应的id做look up操作而得的。其中position_ids是可以由模型自己生成的。值得注意的是,BERT中生成的position embedding的方式类似于word embedding的生成方式,也被称为parametric(参数式),对应的则是Transformer中的functional(函数式)。得到三种embedding之后,模型会将三者相加,一并输入模型中做后续操作。为什么这三个embedding可以相加呢?不会改变向量原本的方向从而失去一定语义信息吗?事实上,这种element-wise summation就等同于先将三种embedding向量拼接在一起,然后再与一个大的look-up table相乘,这种拼接本质上就是做特征融合。举个例子,某个token的三种独热向量分别是\([0,1,0,0]\)、\([1,0]\)和\([1,0,0]\)。下图是三个向量分别和矩阵做乘法最后相加得到的结果。

下图则是三个向量先做拼接后再与一个由上述三个矩阵拼接而成的大矩阵相乘得到的结果。

可以看到,这两种方式得到的结果是一致的。因此可以认为三个embedding相加就是在做特征融合

2) 中间层

从上面结构图可知,中间部分采用的是Transformer的encoder。encoder的结构如下。

Multi-head Attention

多头自注意力机制是BERT最关键的部分之一。略微不同的是,在微调阶段,BERT的几个矩阵中的权值都是预先训练好的,仅需在下游任务训练时进行微调。

Add&Norm

这部分看起来就两个词,实际上包含了两种机制/技术。一是skip connect残差连接,二是Layer Normalization层标准化。通常认为,残差连接在《Deep Residual Learning for Image Recognition》被提出后广受欢迎。它的作用就在于减缓反向传播时导致的梯度消失以及深层网络的退化现象。下图展示了残差连接的结构,BERT中的add指的就是将原输入与经过多头自注意力机制之后的结果相加起来

Layer Normalization,即层标准化,是对应于Batch Normalization的另一种标准化方式,在《Layer Normalization》中被提出。与Batch Normalization不同的是,Layer Normalization是对于同一层中所有节点进行标准化,在NLP问题中就是对某一个词的向量进行标准化。原文中用以下的公式来对第\(l\)层进行Layer Normalization:

\[\mu^l=\frac{1}{H}\Sigma_{i=1}^Ha_i^l \qquad \qquad \sigma^l=\sqrt{\frac{1}{H}\Sigma_{i=1}^H(a_i^l-\mu^l)^2} \qquad \qquad y=\frac{g}{\sigma^l}\odot(x^l-\mu^l)+b \]

其中\(H\)代表这一层中节点的个数,即词向量的维度,\(g\)和\(b\)分别叫做gain和bias参数,用于仿射变换,实际上就是乘以\(g\)做放缩,再加上\(b\)做平移。而在PyTorch中是用下面这个公式去计算的

\[y=\frac{x-E[x]}{\sqrt{Var[x]+\epsilon}}*\gamma+\beta \]

\(\epsilon\)是一个非常小的数,作用是防止分母为0,\(\gamma\)和\(\beta\)就是上述两个参数。PyTorch中nn.LayerNorm类的定义如下:

torch.nn.LayerNorm(normalized_shape, eps=1e-05, elementwise_affine=True, device=None, dtype=None)

举个例子来说明这个类怎么用。

text = torch.FloatTensor([[[1,3,5],  
                          [1,7,8]],  
                         [[2,4,6],  
                          [3,2,1]]])  
layer_norm = nn.LayerNorm(3)  
print(layer_norm(text))
tensor([[[-1.2247,  0.0000,  1.2247],
         [-1.4018,  0.5392,  0.8627]],

        [[-1.2247,  0.0000,  1.2247],
         [ 1.2247,  0.0000, -1.2247]]], grad_fn=<NativeLayerNormBackward0>)

text是一个2*2*3的张量,可以理解为batch*seq_len*embedding_dim,即batch数为2,文本长度为2,词向量维度为3。我们可以看到,输出也是一个2*2*3的张量,那么其中元素数值是怎么算的呢?此时normalised_shape参数传入的是3,即输入维度最后一维的size,那么就会沿着最后一维求出均值\(E[X]\)和方差\(Var[x]\)。此处

\[E[x]=\begin{bmatrix} 3\\ \frac{16}{3}\\ 4\\ 2 \end{bmatrix} \]

\[Var[x]=\begin{bmatrix} \frac{8}{3}\\ \frac{86}{9}\\ \frac{8}{3}\\ \frac{2}{3} \end{bmatrix} \]

再根据上述公式计算Layer Normalization之后的值。举个例子,第1行(从0开始)第0列的\(1-\frac{16}{3}=-\frac{13}{3}\),除以\(\sqrt{\frac{86}{9}+0.00001}\),得到的就是-1.4018。注意,此时elementwise_affine为True,weight和bias参数的shape和normalised_shape是一致的,二者中的元素分别初始化为1和0。而当elementwise_affine为False时,得到的结果如下。此时是少了两个可学习的参数,并且不参与梯度计算。关于Normalization可以参考

tensor([[[-1.2247,  0.0000,  1.2247],
         [-1.4018,  0.5392,  0.8627]],

        [[-1.2247,  0.0000,  1.2247],
         [ 1.2247,  0.0000, -1.2247]]])

值得注意的是,Add&Norm这部分在代码中实际上还包括dropout,原文作者有提到dropout之后的效果更好。

Feed Forward

Transformer模型原文中的公式是

\[FFN(x) = max(0,xW_1+b_1)W_2+b_2 \]

实际上就是两层全连接层,中间隐层用的激活函数是ReLU函数。在PyTorch中的代码实现如下:

class FeedForward(nn.Module):  
	'''  
	原文中隐层维度为3072,输入和输出维度即d_model = 768  
	'''
    def __init__(self, input_dim, hidden_dim = 2048): 
        super(FeedForward,self).__init__()  
        self.fc1 = nn.Linear(input_dim, hidden_dim)  
        self.fc2 = nn.Linear(hidden_dim, input_dim)  
  
    def forward(self, x):  
        out = self.fc1(x)  
        out = F.relu(out)  
        out = self.fc2(out)  
        return out

而不一样的是,在BERT模型中,使用的激活函数是GELU。GELU,高斯误差线性单元激活函数Gaussian Error Linear Units,可以被看作是ReLU函数的平滑版,毕竟ReLU并非处处可导。在BERT源码中是这样写的

def gelu(input_tensor): 
	cdf = 0.5 * (1.0 + tf.erf(input_tensor / tf.sqrt(2.0))) #用erf函数近似
	return input_tesnsor*cdf

下图展示了GELU与ReLU函数的对比图,橙色的是GELU函数,蓝色的是ReLU函数。可以看到,GELU函数在0点处是可导的。

3) 输出层

根据Hugging Face的官方文档,BERT本身的输出的有四个。

  1. last_hidden_state:这是模型最后一层输出的隐藏状态,shape是[batch_size, seq_len, hidden_dim],而hidden_dim = 768;
  2. pooler_output:这就是\([CLS]\)字符对应的隐藏状态,它经过了一个线性层和Tanh激活函数进一步的处理。shape是[batch_size, hidden_dim]
  3. hidden_states:这是可选项,当output_hidden_states = True时会输出。它是一个包含了13个torch.FloatTensor的元组,每一个张量的shape均为[batch_size, seq_len, hidden_dim]。根据文档,这13个张量分别代表了嵌入层和12层encoder的输出。例如hidden_states[0]就代表嵌入层的输出,hidden_states[12]就是最后一层的输出,即last_hidden_state
  4. attentions:这是可选项,当output_attentions = True时会输出。它是一个12个torch.FloatTensor元组,包含了每一层注意力权重,即经过自注意力操作中经过Softmax之后得到的矩阵。每一个张量的shape均为[batch_size, num_head, seq_len, seq_len]。

由于BERT是一个预训练模型,因此最终的输出层是根据下游任务不同而变化的。下图是BERT原文中展示的几个下游任务以及BERT是怎么做的。句子对分类任务以及单句的分类任务都是通过\([CLS]\)字符输出class label的,一般来说后面接个全连接层就可以将向量从768维映射为目标维数,再接一个Softmax函数就可以变为概率分布,从而完成分类。上文提到,\([CLS]\)可以理解为整个句子的句向量,因此可以用作分类任务。(d)中提到的则是实体标注的任务,即对句子中每个token的词性或者其他属性进行标注,因此需要对每个token都进行输出。

(c)中展示的是BERT用于问答任务(其实是阅读理解)。在此类任务中,BERT要求将问题和答案所在参考文本拼接在一起,中间用\([SEP]\)作为分隔。此处可以当成句子对的任务来看,因此需要显式指定\(segment\_id\)。
BERT阅读理解1|500
那么BERT是怎么从文本中找到对应答案的呢?BERT是将某一个范围的文本"高亮"出来,以表示选出来的答案。这本质上就是预测哪个token作为开始,哪个token作为结束。下图描述的是将文本中每一个token对应的最终embedding向量与start token分类器的权重做点乘,再经过Softmax函数得到概率分布,以此选出得分最高的token作为start token。这个start token分类器只有一套权重,作用于文本中每一个token。同样地,end token也是这么被找到的,只不过用的是end token分类器。
BERT阅读理解2
BERT阅读理解3

三、BERT是怎么进行预训练的?

上文提到,BERT属于预训练模型,而根据下游任务的不同再进行微调。当然,也可以选择不微调,Huggingface的Transformer库里提供了很多已经可以直接拿来解决不同下游任务的预训练模型,例如BertForQuestionAnswering,BertForSequenceClassification等等。那么BERT是怎么进行预训练的呢?BERT是针对两个任务进行预训练的。

1.Masked Language Model

简单来说,这个预训练任务就是一个完型填空的任务,即通过上下文判断出某一位置应该是什么词。这一任务是受到了ELMo和GPT的启发。在GPT中,训练语言模型的时候用的是Decoder,这就导致它有一个必须从左到右预测的限制,因为解码器中存在masked multi-head attention。因此,GPT只训练出提取上文信息预测下文的能力,而没有使用下文。而ELMo看上去用了双向,但实际上是分别以\(P(w_i|w_1,w_2,\cdots,w_{i-1})\)和\(P(w_i|w_{i+1},\cdots,w_n)\)作为目标函数,这两个目标函数在训练过程中都只考虑了单向的上文或下文,只是在得到representation时拼接在一起。但BERT不一样,它是以\(P(w_i|w_1,\cdots,w_{i-1},w_{i+1},\cdots,w_n)\)作为目标函数的,也就是考虑了上下文

原文中,作者在输入的序列里随机选中15%的词用\([MASK]\)字符替换掉,然后让BERT去预测这个词。但后来这也导致了一个问题:在微调阶段\([MASK]\)字符是不会出现的,所以就产生了不匹配。因此,作者对这15%的词做了以下改动:

  1. 其中80%仍用\([MASK]\)字符替换
  2. 10%用随机的词语替换
  3. 10%保持原来的词

细节

  1. 引入\([MASK]\)字符是为了显示地告诉模型”当前这个词你得从上下文去推断,我不会告诉你“。实际上这就是一种Denoising Autoencoder的思路,那些被替换掉的位置就相当于引入了噪音,BERT的这种预训练方式也被称为DAE LM(Denosing Autoencoder Language Model)。
  2. 为什么这15%的词不能全部都用\([MASK]\)去替换?倘若这么做,在微调阶段,模型见到的都是正常的词语而没有\([MASK]\),它就只能完全基于上下文信息来推断当前词,而无法利用当前词本身的信息,毕竟它们从未在预训练阶段出现过。
  3. 为什么要引入随机词语?如果按照80%用\([MASK]\)字符,剩下20%用于原词语,那么模型就会学到“如果当前词语是\([MASK]\),那么就从上下文去推断;如果当前词语是一个正常词语,那么答案就是这个词“这一模式。这样一来,在微调阶段模型见到的都是正常的词语,模型就直接”照抄“所有的词,而不会提取上下文的信息了。以一定概率引入随机词语,就是想让模型无论什么情况下,都要把当前token信息和上下文信息结合起来,从而在微调阶段才能提取这两方面的信息,因为它不知道当前的词语是否是”原来的词“。并且,随机词语的替换仅占1.5%(10%*15%),因此对于模型的语言理解能力没有什么影响。

2. Next Sentence Prediction

此任务是让模型预测下一个句子是否真的是当前句子的下一句。起因是很多重要的下游任务例如问答(QA)和自然语言推理(NLI)都基于两个句子之间的关系,因而此任务就可以使得模型学习提取两个句子之间关系的能力。具体做法如下:

  1. 选择句子A和B作为输入,将两个句子首尾相接拼接起来,中间用\([SEP]\)连接。
  2. 其中50%的时间里,选择的B是A的真实的下一句。
  3. 剩下50%的时间里,随机选择B,只要不是A的下一句即可。
    下图即为NSP任务的一个例子
    NSP任务

3. 关于MLM和NSP的其他问题

损失函数

BERT的损失函数由两部分组成:MLM任务的损失函数+NSP任务的损失函数,用公式表示即为:

\[L(\theta,\theta_1,\theta_2) = L_1(\theta,\theta_1)+L_2(\theta,\theta_2) \]

其中\(\theta\)指的是encoder部分中的参数,\(\theta_1\)指的是MLM任务在encoder部分之后接的输出层中的参数,\(\theta_2\)指的是NSP任务中encoder后接上的分类器的参数。

而对于MLM任务,实际上也就是一个分类的任务。倘若所有被遮盖/替换的词语的集合是M,而vocabulary的长度为\(|V|\),那么这就是一个\(|V|\)分类的问题。下面这个公式就是负对数似然函数,最小化这个函数就等同于最大似然估计,即求得一组\(\theta\)和\(\theta_1\),使得N个\(m_i\)出现的概率最大。

\[L_1(\theta, \theta_1) = -\Sigma^N_{i=1}\log p(m=m_i|\theta,\theta_1), m_i \in [1,2,...,|V|] \]

再来看看NSP任务的损失函数。NSP可以看作是一个二分类的文本分类任务,只需要将\([CLS]\)的输出接入一个全连接层作为分类器。

\[L_2(\theta,\theta_2) = -\Sigma_{j=1}^N \log p(n=n_i|\theta,\theta_2), n_i \in[IsNext, NotNext] \]

加在一起就是

\[L(\theta, \theta_1, \theta_2) = -\Sigma^N_{i=1}\log p(m=m_i|\theta,\theta_1) - \Sigma_{j=1}^N \log p(n=n_i|\theta,\theta_2) \]

其他细节

  1. 借鉴Adherer要加油呀~ 的说法,具体的预训练工程实现细节方面,BERT 还利用了一系列策略,使得模型更易于训练,比如对于学习率的 warm-up 策略,使用的激活函数不再是普通的 ReLu,而是 GeLu,也使用了 dropout 等常见的训练技巧。
  2. 由上述损失函数可以推断出来,MLM和NSP这两个预训练是联合训练的,也就是一起训练的。
  3. 在BERT后续的变体模型RoBERTa的论文里,被提出NSP这个预训练任务不但没有使下游任务微调时有明显的受益,甚至还会有负面作用,所以干脆直接不用NSP了。

四、如何使用BERT?

下面用一个简单的例子来展示bert_case_chinese这个预训练模型是怎么用的,其他版本的也都是大同小异了。以下内容参考Pytorch-Bert预训练模型的使用(调用transformers)

首先下载transformers模块,这个模块包含了很多NLP和NLU中会使用的预训练模型,包括BERT、GPT-2、RoBERTa等等。从transformers模块中引入BertModel、BertTokenizer和BertConfig类。同时还需要引入torch模块。

!pip install transformers
from transformers import BertModel, BertTokenizer, BertConfig
import torch

值得注意的是,由于我使用的是Google Colab平台,直接from transformers import BertModel会从官方的s3数据库下载模型配置、参数等信息,这在大陆并不可用。因此一般来说就需要手动下载模型,下载bert-base-chinese,里面包含config.josn,vocab.txt,pytorch_model.bin三个文件,将其放在对应的文件夹内。

下面则是导入分词器、配置和模型

#通过词典导入分词器
tokenizer = BertTokenizer.from_pretrained('bert-base-chinese')
#导入配置文件
model_config = BertConfig.from_pretrained('bert-base-chinese')
#修改配置
model_config.output_hidden_states = True
model_config.output_attentions = True
#通过配置和模型id来导入模型
model = BertModel.from_pretrained('bert-base-chinese', config = model_config)

接着开始分词。此处设定最大长度为10,过长的会被截断,而不够长的会用\([PAD]\)补长

text = ['你真的很好看。','这个牌子的咖啡很好喝。']
encoding_results = list()
for txt in text:
	encoding_results.append(tokenizer.encode_plus(txt, max_length = 10, padding = 'max_length', truncation = True))
print(encoding_results)
[{'input_ids': [101, 872, 4696, 4638, 2523, 1962, 4692, 102, 0, 0], 'token_type_ids': [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 'attention_mask': [1, 1, 1, 1, 1, 1, 1, 1, 0, 0]}, 
{'input_ids': [101, 6821, 702, 4277, 2094, 4638, 1476, 1565, 2523, 102], 'token_type_ids': [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 'attention_mask': [1, 1, 1, 1, 1, 1, 1, 1, 1, 1]}]

打印的结果就是encode_plus()返回的结果。encode_plus()返回的是两个字典,每个字典包含以下三个元素:

  1. input_ids:每个token在词典中的index。例如此处\([CLS]\)和\([SEP]\)token分别对应的是101和102,而补长的token则是0。
  2. token_type_ids:上文提到的用于查找segment embedding的id,即用于区分两个句子的编码。
  3. attention_mask: 指定对于哪些token进行attention操作。例如此处第一个句子最后补长的部分则不进行attention操作。
    除此之外,也可以用encode()来进行分词,只不过只会返回input_ids。接着让我们看看分词后句子变成了什么。
for res in encoding_results:
	print(tokenizer.convert_ids_to_tokens(res['input_ids']))
['[CLS]', '你', '真', '的', '很', '好', '看', '[SEP]', '[PAD]', '[PAD]']
['[CLS]', '这', '个', '牌', '子', '的', '咖', '啡', '很', '[SEP]']

可以看到,文本被切分成一个个的字,首尾分别添加上了\([CLS]\)和\([SEP]\)字符,并且补偿部分用的是\([PAD]\)字符。接着将字典中的三个元素取出来,放入列表后组成张量作为模型输入。

input_ids = list()
type_ids = list()
mask_ids = list()

for res in encoding_results:
	input_ids.append(res['input_ids'])
	type_ids.append(res['token_type_ids'])
	mask_ids.append(res['attention_mask'])

#将三个列表转化为张量
input_ids = torch.tensor(input_ids)
type_ids = torch.tensor(type_ids)
mask_ids = torch.tensor(mask_ids)

输入模型之后,得到返回值。返回值是一个字典,我们先查看它的keys。

outputs = model(input_ids, token_type_ids = type_ids, attention_mask = mask_ids)
print(outputs.keys())
#odict_keys(['last_hidden_state', 'pooler_output', 'hidden_states', 'attentions'])

可以看到,keys包含了上述的四个输出,由于config部分将两个参数调为了True,因此也会输出hidden_states和attentions。至此,关于BERT如何使用的部分就结束了。现在看看输出部分。

print(outputs['last_hidden_state'].shape)
print(outputs['pooler_output'].shape)
torch.Size([2, 10, 768]) 
torch.Size([2, 768])

刚好对应上batch_size = 2, seq_len = 10和hidden_dim = 768。

print(len(outputs['hidden_states']))
print(len(outputs['attentions']))
print(outputs['hidden_states'][8].shape)
print(outputs['attentions'][1].shape)
13
12
torch.Size([2, 10, 768]) 
torch.Size([2, 12, 10, 10])

前两个结果说明,attentions是不算上embedding层的,因此只有12个元素;而hidden_states则是包含了embedding层的输出,所以一共有13个元素。另外后两个结果也正好对应了上文的shape。另外,如果下游任务需要进行微调,就需要定义优化器和损失函数。损失函数根据不同下游任务有不同的选择,例如多分类任务可以使用交叉熵函数;而优化器一般选择的是\(AdamW\)优化器,具体可以看这里[[关于优化(一)]]。

五、一些细节

1. Feature-based和Fine-tuning

在BERT的论文中,作者提到了ELMo是属于Feature-based,而GPT和BERT属于Fine-tuning(当然,BERT也可以用feature-based方法)。

Feature-based就是通过训练神经网络语言模型,而其中的权重是可以拿来当作词语的embedding的。简单来说,feature-based要的不是整个语言模型,而是其中的”中间产物”,即embedding,再用这些embedding去作为下游任务的输入。最经典的例子就是ELMo和Word2Vec。

对于静态词向量例如Word2Vec和Glove,其做法就是查表。也就是输入某一个词的one-hot编码,然后查找对应的词向量,并且得到的词向量用以下游任务;对于动态词向量例如ELMo和BERT,是将下游任务的数据输入至模型中,得到每个词的embedding,再用于下游任务中。由此也可以看出,静态词向量是指在训练后不再发生改变,而动态词向量会根据上下文的不同而变化。

Feature-based方法分为两个步骤:

  1. 首先在大的语料A上无监督地训练语言模型,训练完毕得到语言模型。
  2. 然后构造task-specific model例如序列标注模型,采用有标记的语料B来有监督地训练task-sepcific model,将语言模型的参数固定,语料B的训练数据经过语言模型得到LM embedding,作为task-specific model的额外特征。

Fine-tuning则不同,此类方法是将整个模型拿过来,再根据下游任务的不同进行添加或者修改,使其输出符合任务需要。一般来说都是在模型的最后一层或者现有模型结构之后添加上一层网络结构以匹配各种下游任务。GPT-1、GPT-2和BERT就用到了Fine-tuning。

Fine-tune分为两个步骤:

  1. 构造语言模型,采用大的语料A来训练语言模型
  2. 在语言模型基础上增加少量神经网络层来完成specific task例如序列标注、分类等,然后采用有标记的语料B来有监督地训练模型,这个过程中语言模型的参数并不固定,依然是trainable variables。

2. BERT是如何解决一词多义问题的?

所谓一词多义,就是指相同的词在不同上下文语境中有可能意思不同。例如"这个苹果真好吃"和“今年苹果手机又涨价了”,这其中的“苹果”一词代表的就是不同意思。而静态词向量如Word2Vec和GloVe,训练好之后是通过查表(即look up)的方式取得对应的词向量的,在这种情况下词向量是固定的,因此不论上下文怎么变化,使用的都是这个词向量。

上文提到,BERT是动态词向量,因此可以解决一词多义的问题。这是因为对于某一个词,BERT会让其学习到上下文信息并结合自身信息,因此经过十二层encoder之后得到的词向量就会根据上下文的不同而改变,这是多头注意力机制的作用。

3. BERT的双向体现在哪里?

BERT的全称是Bidirectional Encoder Representation of Transformer,其双向就体现在encoder做self-attention操作时除了当前的词/token以外,还同时使用了上下文的词/token作为输入,同时学习到了上文和下文的信息,这也是MLM任务的作用。

4. BERT的参数量

此处以\(BERT_{BASE}\)为例
输入部分的参数量:(30522+2+512)*768
中间层对于每一个encoder(算上bias):

  • attention机制的参数=768*768/12*3*12(12个头)+768/12*12*3
  • 将每个头拼接在一起并经过一个全连接层= 768/12*12*768+768
  • LayerNorm层参数=768* +768
  • 两层前馈层=768*3072+3072+3072*\768+768
  • LayerNorm层参数=768+768

中间层参数求和后乘以12,最终得到108890112,即约为110M。

5. BERT在预训练时构造的样本长度

为了不浪费算力同时也节省训练时间,在预训练阶段,BERT在前90%的时间里都将样本长度设定为128,后10%的时间为了训练位置编码才设定为512

6. BERT的每一层都学到了什么?

关于这一点可以参考此文ACL 2019 | 理解BERT每一层都学到了什么,原论文为What does BERT learn about the structure of language?

7. 其他

关于其他细节,可以参考关于BERT中的那些为什么

参考文章

  1. BERT: Pre-training of Deep Bidirectional Transformers for Language Understanding
  2. 【译】为什么BERT有3个嵌入层,它们都是如何实现的
  3. The Illustrated Transformer
  4. 超详细图解Self-Attention
  5. This post is all you need(①多头注意力机制原理)
  6. transformers库中BertModel中的hidden_states元组的内容是如何排列的
  7. Pytorch-Bert预训练模型的使用(调用transformers)
  8. 手把手教你用Pytorch-Transformers——部分源码解读及相关说明(一)
  9. 简单说明一下BERT模型相比ELMo模型有哪些优缺点?
  10. BERT参数量计算
  11. 关于BERT中的那些为什么
  12. NLPer看过来,一些关于BERT的问题整理记录
  13. BERT模型的损失函数怎么定义的?
  14. 关于bert的输出是什么
  15. BERT模型返回值
  16. 理解tokenizer之WordPiece: Subword-based tokenization algorithm
  17. 为什么bert的词向量是动态的,与word2vec的区别是什么?
  18. 浅谈feature-based 和 fine-tune
  19. Question Answering with a Fine-Tuned BERT

标签:BERT,模型,ids,token,详解,embedding,theta
From: https://www.cnblogs.com/JuggyZhan/p/18249075

相关文章

  • 传统后端SQL数据层替代解决方案: 内置数据源+JdbcTemplate+H2数据库 详解
    内置数据源我们回顾一下druid数据源的配置方式通过type属性指定数据源的类型导入依赖starter就使用了spring的自动装配功能格式二是在引入druid的依赖的基础上进行的一种配置方式Tomcat内部也可以进行数据源的配置轻量级中最快的数据源对象我们切换德鲁伊连接池......
  • C语言详解(预编译)
    Hi~!这里是奋斗的小羊,很荣幸您能阅读我的文章,诚请评论指点,欢迎欢迎~~......
  • caddy run 命令详解
     caddyrun这个子命令启动Caddy进程,并可选地使用初始配置文件进行引导,并无限期地阻塞,直到服务器停止运行,即run是在"守护进程"模式(前台)中运行Caddy。使用参数:[--config<path>[--adapter<name>]][--envfile<path>][--environ][--resume][--watch][--pidfile<......
  • Spring框架的原理及应用详解(一)
    本系列文章简介:        在当今的软件开发世界中,随着应用复杂性的不断增加和技术的快速发展,传统的编程方式已经难以满足快速迭代、高可扩展性和易于维护的需求。为此,开发者们一直在寻求更加高效、灵活且易于管理的开发框架,以帮助他们应对这些挑战。Spring框架就是在这......
  • Spring框架的原理及应用详解(二)
    本系列文章简介:        在当今的软件开发世界中,随着应用复杂性的不断增加和技术的快速发展,传统的编程方式已经难以满足快速迭代、高可扩展性和易于维护的需求。为此,开发者们一直在寻求更加高效、灵活且易于管理的开发框架,以帮助他们应对这些挑战。Spring框架就是在这......
  • QVector使用详解
    QVector使用详解一、创建和初始化1.默认构造函数2.指定大小的构造函数3.指定大小和初始值的构造函数4.使用std::initializer_list初始化5.拷贝构造函数6.从指针和大小初始化7.使用std::vector初始化8.使用`QList`初始化9.使用`QVector`初始化`QList`1......
  • 关于Unity四种合批技术详解
    文章目录一.静态合批(StaticBatching)1.启用静态合批2.举例说明3.静态合批的限制4.静态合批的优点缺点5.动态指定物品合批二.动态合批(DynamicBatching)1.启用动态合批2.合批规则3.举例说明4.使用限制三.GPUInstancing1.启用GPUInstancing2.启用限制3.举例说明......
  • shell编程-sed命令详解(超详细)
    目录前言一、sed命令介绍1.sed命令简介2.sed命令的基本语法3.常用的sed命令选项4.常用的sed编辑命令二、sed命令示例用法1.替换文本2.删除匹配行3.打印特定行4.在指定行之前插入文本5.从文件中读取编辑命令6.使用sed命令在core-site.xml文件中的<configuration>标签之前......
  • 网页请求和响应过程详解
    网页请求和响应过程详解1.引言在Web开发中,理解浏览器和服务器之间的请求和响应过程是至关重要的。这不仅有助于我们优化网站性能,还能帮助我们排查各种网络问题。本文将详细介绍从浏览器发送请求到服务器返回响应的全过程,涵盖各个技术细节。2.浏览器端处理当用户在浏览器地址......
  • HashMap的使用方法详解
    HashMap是一种常用的数据结构,用于存储键值对(key-valuepairs),其中键(key)是唯一的。 HashMap提供了多种方法用于添加、删除、遍历和查询键值对。一、添加方法put方法,可以单次向HashMap中添加一个键值对。注意:添加到Map中的数据,与List不一样,是没有顺序的。顺序是根据哈希算法得......