Bert(Pre-training of Deep Bidirectional Transformers for Language Understanding)模型采用的是 Transformer 的 Encoder 部分,Transformer Encoder 部分包含 6 个编码器层,而 Bert 则不同:
- Bert-Base: 有 12 个 网络层数(encoder), 隐藏状态有 768 维, 使用 12 个 注意力头,参数总量 110 MB;
- Bert-Large: 有 24 个网络层数,隐藏状态有 1024 维,使用 16 个注意力头,参数有 330 MB.
1. Bert 模型输入
Bert 模型的输入是由三个 Embedding 来构成,分别是:
- Token Embedding
- Position Embedding
- Segment Embedding
- Bert Input Embedding = Token Embedding + Segment Embedding + Position Embedding
Bert 模型支持输入 2 个句子,Segment Embedding 是为了区分两个句子。在 Transformer 中 Position Embedding 是由公式来确定的,而在 Bert 中 Position Embedding 是可学习的。在 Bert 中 Embedding 的维度是 768 维。
BertEmbedding 实现代码如下:
class BertEmbeddings(nn.Module): """Construct the embeddings from word, position and token_type embeddings.""" def __init__(self, config): super().__init__() # config.vocab_size 为 30522 config.hidden_size 为 768 # word_embeddings 将会对输入的序列转换为词嵌入编码 self.word_embeddings = nn.Embedding(config.vocab_size, config.hidden_size, padding_idx=config.pad_token_id) # config.max_position_embeddings, 为 512 config.hidden_size 为 768 # 从这里可以看到 Bert 对输入的句子的最大长度要求是 512, 超过 512 长度就不能获取位置编码 self.position_embeddings = nn.Embedding(config.max_position_embeddings, config.hidden_size) # config.type_vocab_size 为 2,因为 Bert 一次输入最多 2 个句子 config.hidden_size 为 768 # 可以获得两个不同句子的段位置编码 self.token_type_embeddings = nn.Embedding(config.type_vocab_size, config.hidden_size) # 标准化层和丢弃层 self.LayerNorm = nn.LayerNorm(config.hidden_size, eps=config.layer_norm_eps) self.dropout = nn.Dropout(config.hidden_dropout_prob) # position_ids (1, len position emb) is contiguous in memory and exported when serialized self.position_embedding_type = getattr(config, "position_embedding_type", "absolute") self.register_buffer("position_ids", torch.arange(config.max_position_embeddings).expand((1, -1))) if version.parse(torch.__version__) > version.parse("1.6.0"): self.register_buffer( "token_type_ids", torch.zeros(self.position_ids.size(), dtype=torch.long, device=self.position_ids.device), persistent=False, ) def forward( self, input_ids=None, token_type_ids=None, position_ids=None, i nputs_embeds=None, past_key_values_length=0 ): if input_ids is not None: input_shape = input_ids.size() else: input_shape = inputs_embeds.size()[:-1] seq_length = input_shape[1] if position_ids is None: position_ids = self.position_ids[:, past_key_values_length : seq_length + past_key_values_length] if token_type_ids is None: if hasattr(self, "token_type_ids"): buffered_token_type_ids = self.token_type_ids[:, :seq_length] buffered_token_type_ids_expanded = buffered_token_type_ids.expand(input_shape[0], seq_length) token_type_ids = buffered_token_type_ids_expanded else: token_type_ids = torch.zeros(input_shape, dtype=torch.long, device=self.position_ids.device) if inputs_embeds is None: inputs_embeds = self.word_embeddings(input_ids) token_type_embeddings = self.token_type_embeddings(token_type_ids) # 输入的词嵌入编码 + 段位置编码 embeddings = inputs_embeds + token_type_embeddings if self.position_embedding_type == "absolute": position_embeddings = self.position_embeddings(position_ids) # 词嵌入编码 + 每一个词的位置编码 embeddings += position_embeddings embeddings = self.LayerNorm(embeddings) embeddings = self.dropout(embeddings) return embeddings
另外,Bert 对输入的内容默认会添加一些特殊的 Token,如下所示:
from transformers import BertTokenizer if __name__ == '__main__': tokenizer = BertTokenizer.from_pretrained('bert-base-chinese') outputs = tokenizer('不畏鸿门传汉祚') print(outputs) print(tokenizer.convert_ids_to_tokens(101)) print(tokenizer.convert_ids_to_tokens(102))
程序输入结果:
{ 'input_ids': [101, 679, 4519, 7896, 7305, 837, 3727, 4864, 102], 'token_type_ids': [0, 0, 0, 0, 0, 0, 0, 0, 0], 'attention_mask': [1, 1, 1, 1, 1, 1, 1, 1, 1] } [CLS] [SEP]
我们输入的是 “不畏鸿门传汉祚” , Bert 添加特殊 Token 之后实际的输入为 “[CLS]不畏鸿门传汉祚[SEP]”,[CLS] 是 classification 的简写,其作用是当我们的输入的句子经过 Bert 之后,Bert 会提取到句子的语义表示,这个 CLS 位置的向量就可以当做句子的语义表示,此时,既然我们拿到了 Bert 对句子的理解表示,就可以将其用于分类任务。[SEP] 则表示句子的结束。
2. Masked Language Model
什么是预训练任务?简单理解为,我们为了得到每个词更好的上下文表示,需要设定一些训练任务,让模型按照我们指定的任务在没有人工标注的数据集上进行监督学习,从而获得每个词、句子更好的上下文表示,以便能够将结果用于下游任务。
Bert 模型在训练过程中引入了两个预训练任务,分别是 Masked Language Model 和 Next Sentence Prediction,这两个任务的损失之和共同构构成了 BERT 的优化目标,即:最小化这两个任务的损失。
if labels is not None and next_sentence_label is not None: loss_fct = CrossEntropyLoss() masked_lm_loss = loss_fct(prediction_scores.view(-1, self.config.vocab_size), labels.view(-1)) next_sentence_loss = loss_fct(seq_relationship_score.view(-1, 2), next_sentence_label.view(-1)) total_loss = masked_lm_loss + next_sentence_loss
从实现代码可以看出 total_loss = masked_lm_loss + next_sentence_loss。
MLM 叫做掩码语言模型,在训练时,它会随机把输入中的 15% Token 进行随机掩盖,被掩掉的词会使用下面的词来替换:
- 10% 使用其他任意的单词替换
- 10% 仍然使用原单词,相当于没有使用其他 Token 替换
- 80% 使用 [MASK] Token 来替换
输入处理完之后,我们用被掩盖的词所在位置的输出来预测被掩盖的词。基于 MLM 预训练任务,我们训练出的词向量可以很好的根据整篇文章的语境来表示每个词的含义。
3. Next Sentence Prediction
问答任务(QA):输入一个句子对(Question, Context),Context 为一段文本,Question 表示基于这段文本的问题,训练的目标的是预测出 Question 在 Context 中答案的位置(包含开始和结束)。
自然语言推理任务(NLI):训练的目标是输入两个句子,预测两个句子的关系,比如:两个句子的语义的相似程度。
我们可以简单理解到,这些任务都需要模型对句子的语义进行很好的理解和表示,MLM 更侧重于去理解词以及建立对该词的语义表示。
Next Sentence Prediction 预训练任务就是针对该问题而提出的,它要求输入两个句子 A 和句子 B,然后判断句子 B 是否是句子 A 的下一句。
在训练前,我们首先从语料中构建输入句子A,50% 从使用原始语料中选择连续的下一个句子 B 组成输入正样本,标记为 IsNext。 50% 从语料中随机挑选一些不挨着的句子 B 组成负样本,标记为 NotNext。最终,使用 [CLS] 对应的输出计算概率句子B是句子A 的 IsNext 和 NotNext 的概率。
4. Fine-Tuning
单句子分类任务、句子对句子分类任务,可以将 [CLS] token 的 last hidden state 送入到 Linear 层得到预测的 logits,再将 logits 经过 softmax 得到最终的预测标签的概率输出。
对于句子标记类的任务,例如:命名实体识别,可以将输入中的不同 token 对应的 last hidden state 送入到 Linear + SoftMax 得到标签的预测概率。
由于 BERT 模型有多个 hidden state,理论上可以使用任意的一层,或者几层的 hidden state 来预测输出。需要注意的是:使用不同的层带来的效果可能是不同的。
4. Bert 模型输出
以 huggingface transformers Bert 模型输出为例:
from transformers import BertTokenizer from transformers import BertModel from transformers import BertConfig if __name__ == '__main__': checkpoint = 'bert-base-chinese' config = BertConfig.from_pretrained(checkpoint) # 设置输出各个层隐藏状态 config.output_hidden_states = True # 设置输出各个层注意力权重 config.output_attentions = True model = BertModel.from_pretrained(checkpoint, config=config) tokenizer = BertTokenizer.from_pretrained(checkpoint) inputs = tokenizer('窗前明月光', return_tensors='pt') print(inputs) outputs = model(**inputs) # 打印输出中的所有 key # 输出: odict_keys(['last_hidden_state', 'pooler_output', 'hidden_states', 'attentions']) print(outputs.keys()) print(outputs.last_hidden_state.shape) # 输出: 13 torch.Size([1, 7, 768]) # 13 表示第一个是输入的 Embedding, 其余 12 表示各个层的 hidden_state 输出 # torch.Size([1, 7, 768]) 表示输出每一层的 7 个输入的 hidden_state print(len(outputs.hidden_states), outputs.hidden_states[1].shape) # 输出: torch.Size([1, 768]) # 表示最终的 [cls] 经过线性层和 tanh 激活后的结果,768 维 # 该结果可以理解为整个输入的语义 print(outputs.pooler_output.shape) # 输出: 12 torch.Size([1, 12, 7, 7]) # 12 表示共有 12 层 # size 中的 12 表示 12 个注意力头 # 7x7 表示词之前相互的注意力权重 print(len(outputs.attentions), outputs.attentions[0].shape)
下面是 pooler_output 的实现源代码:
class BertPooler(nn.Module): def __init__(self, config): super().__init__() self.dense = nn.Linear(config.hidden_size, config.hidden_size) self.activation = nn.Tanh() def forward(self, hidden_states): # 获得输入的 [CLS] token 对应的 768 维张量 first_token_tensor = hidden_states[:, 0] # 将 [CLS] 张量送入线性层 pooled_output = self.dense(first_token_tensor) # 对线性层输出结果进行 Tanh 激活计算 pooled_output = self.activation(pooled_output) return pooled_output