BERT

BERT

BERT(Pre-training of Deep Bidirectional Transformers for Language Understanding)用的是 Transformer 的 Encoder 部分。原版 Transformer 的 Encoder 只堆了 6 层编码器,BERT 在这个基础上做了两种规格:

  1. BERT-Base:有 12 层编码器,隐藏状态 768 维,用 12 个注意力头,参数量约 110 MB。
  2. BERT-Large:有 24 层编码器,隐藏状态 1024 维,用 16 个注意力头,参数量约 330 MB。

1. Bert 输入

BERT 模型的输入由三个 Embedding 相加构成,分别是:

  1. Token Embedding
  2. Position Embedding
  3. Segment Embedding
  4. Bert Input Embedding = Token Embedding + Segment Embedding + Position Embedding

BERT 一次支持输入两个句子,Segment Embedding 就是用来区分这两个句子的。Transformer 里的 Position Embedding 是靠公式算出来的,BERT 里的 Position Embedding 则是可学习的。在 BERT 中,Embedding 的维度是 768 维。

BertEmbeddings 的实现代码如下:

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,
        inputs_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 之后,CLS 这个位置输出的向量可以直接当成整句的语义表示,拿到它之后就能接到分类任务上。[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,被遮住的词按下面三种方式替换:

  1. 10% 换成其他任意一个词。
  2. 10% 保持原来的词不动,相当于没替换。
  3. 80% 用 [MASK] 这个特殊 Token 替换。

输入处理好之后,我们就用被遮住位置的输出,去预测那个被遮住的词是什么。靠着 MLM 这个任务,训出来的词向量就能结合整句话的上下文来表示一个词的含义。

3. Next Sentence Prediction

先说两个下游任务,它们都需要模型真正理解句子之间的关系。一个是问答任务(QA):输入一个句子对(Question, Context),Context 是一段文本,Question 是针对这段文本提的问题,训练目标是预测出答案在 Context 中的位置(包括开始和结束)。

另一个是自然语言推理任务(NLI):输入两个句子,预测它们之间的关系,比如两个句子语义的相似程度。

可以看到,这些任务都要求模型对句子语义有很好的理解和表示,而前面讲的 MLM 更偏重理解单个词、建立词级的语义表示。

Next Sentence Prediction 这个预训练任务就是为补这一块提出的。它输入两个句子 A 和 B,让模型判断 B 是不是 A 的下一句。

训练样本是这样造的:先从语料里取一句 A,再取 B。其中一半,B 直接选语料里紧跟 A 的下一句,作为正样本,标成 IsNext。另一半,B 从语料里随机挑一句和 A 毫不相干的,作为负样本,标成 NotNext。最后用 [CLS] 位置的输出,去算 B 是 IsNext 和 NotNext 各自的概率。

4. Bert 微调

微调时,单句分类和句子对分类这两类任务,做法是把 [CLS] 这个 token 的 last hidden state 送进一个 Linear 层,得到预测的 logits,再过一层 softmax,就得到各标签的概率。

单句子分类任务
句子对句子分类任务

对于逐字标注类的任务,比如命名实体识别,则把每个 token 对应的 last hidden state 分别送进 Linear + Softmax,得到每个字的标签预测概率。

BERT 有很多层 hidden state,理论上你可以挑任意一层、或把几层拼起来做预测。要注意的是,用不同的层,效果可能差得挺多。

5. Bert 输出

最后看一下 BERT 的输出。以 huggingface transformers 里的 BertModel 为例:

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