BERT(Pre-training of Deep Bidirectional Transformers for Language Understanding)用的是 Transformer 的 Encoder 部分。原版 Transformer 的 Encoder 只堆了 6 层编码器,BERT 在这个基础上做了两种规格:
- BERT-Base:有 12 层编码器,隐藏状态 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 一次支持输入两个句子,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,被遮住的词按下面三种方式替换:
- 10% 换成其他任意一个词。
- 10% 保持原来的词不动,相当于没替换。
- 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




冀公网安备13050302001966号