BERT 论文精读

March 2, 2026

BERT 论文精读

BERT: Pre-training of Deep Bidirectional Transformers for Language Understanding

Jacob Devlin, Ming-Wei Chang, Kenton Lee, Kristina Toutanova — NAACL 2019 (Google AI Language)

BERT(Bidirectional Encoder Representations from Transformers)由 Google 于 2018 年提出,通过深度双向 Transformer 编码器和两个创新的预训练任务(MLM + NSP),开创了"预训练 + 微调"范式,在多项 NLP 基准上刷新了当时的最佳成绩。这一工作标志着 NLP 正式进入大规模无监督预训练的时代。

研究动机

预训练模型的两种策略

使用预训练模型做特征表示时,主要有两类策略:

  • 基于特征(Feature-based):为下游任务构造特定的网络结构,将预训练得到的表示作为额外特征与原始输入一起输入模型(如 ELMo)。ELMo 分别独立训练了前向和后向两个单向 LSTM,然后在顶层进行浅层拼接,并非真正的深度双向。
  • 基于微调(Fine-tuning):先在大规模语料上预训练通用模型,然后在下游任务上继续训练全部参数,使模型适应新任务(如 GPT、BERT)。然而 GPT 使用的标准语言模型是单向的(从左到右),每个 token 只能看到它左侧的上下文。

核心问题

对于句子级别的分类任务,单向模型可能勉强够用。但对于 Token 级别任务(如 SQuAD 阅读理解),理解一个词不仅需要知道它前面说了什么,更需要知道它后面说了什么。单向限制对这类任务是极其有害的。

BERT 的核心突破在于:利用 Transformer Encoder 的自注意力机制,使每个位置能同时关注左右两侧的上下文,通过引入 Masked Language Model(MLM)和 Next Sentence Prediction(NSP)两个预训练任务,实现真正的深度双向表示。

模型架构

基础架构

BERT 基于 Transformer 的 Encoder 部分(不使用 Decoder),提供两种规模:

模型层数 (LL)隐藏维度 (HH)注意力头数 (AA)参数量
BERT-Base12768121.1 亿
BERT-Large241024163.4 亿

BERT 预训练架构:深度双向 Transformer Encoder 同时训练 MLM 和 NSP 两个任务

输入表示

BERT 的输入是一个序列,可以是单个句子或两个句子拼接。与原始 Transformer 不同,BERT 只有编码器,通过将句子对拼接为一个序列来处理。

特殊标记

  • [CLS]:始终放在序列开头。经过多层 Transformer 编码后,其输出向量聚合了整个序列的全局信息,用作分类任务的特征。之所以选择 [CLS] 而非其他位置的输出,是因为其他位置的词向量受 MLM 训练目标引导,偏向于表示对应词本身的语义;[CLS] 没有这种偏向,能更纯粹地关联全局信息。
  • [SEP]:用于分隔两个句子。

输入向量由三部分相加得到

BERT 输入表示

E=Etoken+Esegment+EpositionE = E_{\text{token}} + E_{\text{segment}} + E_{\text{position}}
  1. Token Embeddings:基于 WordPiece 分词的词嵌入。WordPiece 将词拆分为子词单元(如 unhappinessun, ##happi, ##ness),有效减少词表规模(约 30,000)并处理未登录词。
  2. Segment Embeddings:区分句子 A 和句子 B 的学习向量(EAE_AEBE_B)。
  3. Position Embeddings:位置编码向量。与 Transformer 使用固定三角函数不同,BERT 的位置编码是学习得到的,最大长度为 512。

预训练任务

BERT 的预训练采用两个任务联合训练(multi-task joint training),总损失 = MLM 损失 + NSP 损失,模型参数同时优化。

Masked Language Model(MLM)

目标:实现深度双向上下文理解。传统语言模型只能单向预测,无法同时利用双向信息。

方法:随机遮盖输入序列中 15% 的 token(不包括 [CLS][SEP]),让模型预测被遮盖的原始词。损失函数只计算被遮盖 token 的预测准确性。

80-10-10 替换策略:被选中的 15% token 并非全部替换为 [MASK],而是采用混合策略:

概率操作示例(原句:my dog is cute,选中 cute
80%替换为 [MASK]my dog is [MASK]
10%替换为随机词my dog is apple
10%保持不变my dog is cute
为什么不全部用 [MASK]
  • 缩小预训练与微调的分布差异:微调/推理时输入中没有 [MASK],全部使用 [MASK] 会导致特征分布不一致,影响泛化能力。
  • 防止过度依赖 [MASK] 标记:10% 保留原 token 迫使模型对所有位置都进行建模——即使看到正常的单词也不能掉以轻心,必须结合全局上下文建立深度表征。
  • 增强泛化能力:让模型在训练时也能见到"正常"输入,有助于在下游任务中更好地泛化到真实数据。

Next Sentence Prediction(NSP)

目标:让模型理解句子间的关系,提升问答(QA)、自然语言推断(NLI)等需要跨句推理的任务表现。

方法

  1. 从语料中随机采样句子对 (A,B)(A, B)
    • 50% 概率:BBAA 的真实下一句(正样本,label=IsNext)
    • 50% 概率:BB 是随机采样的句子(负样本,label=NotNext)
  2. 输入格式:[CLS] 句子A [SEP] 句子B [SEP]
  3. [CLS] 的输出向量 CC,接全连接层进行二分类
  4. 损失函数为二分类交叉熵

示例

  • 正样本:[CLS] The man went to the store. [SEP] He bought a gallon of milk. [SEP]
  • 负样本:[CLS] The man went to the store. [SEP] Penguins are flightless birds. [SEP]

NSP 属于自监督学习——虽然形式上是监督学习(有输入、有标签、有交叉熵损失),但标签由数据本身的结构自动生成,无需人工标注。

微调

将预训练好的 BERT 作为基础,添加简单的输出层(如线性分类器),在具体下游任务上进行有监督微调。不同任务只需更换输出层:

  • 句子级分类(如 NLI、情感分析):将 [CLS] 的最终输出 CC 送入全连接层进行分类,分类层权重为 WRK×HW \in \mathbb{R}^{K \times H}KK 为类别数):
L=log(softmax(CWT))\mathcal{L} = -\log(\text{softmax}(CW^T))
  • Token 级任务(如 SQuAD 阅读理解):引入 Start 向量 SRHS \in \mathbb{R}^H 和 End 向量 ERHE \in \mathbb{R}^H,段落中第 ii 个词作为答案起始位置的概率为:
Pi=eSTijeSTjP_i = \frac{e^{S \cdot T_i}}{\sum_j e^{S \cdot T_j}}
  • 序列标注(NER):对每个 token 的输出做分类

微调非常迅速,单块 TPU 最多 1 小时即可完成大部分任务。推荐超参数:Batch size 16/32,学习率 2e-5 ~ 5e-5,Epochs 2-4。

实验结果

GLUE Benchmark

BERT 在 GLUE 的所有任务上以压倒性优势击败了此前 OpenAI GPT 的最好成绩:

系统MNLI (Acc)QNLI (Acc)QQP (F1)SST-2 (Acc)平均
Pre-OpenAI SOTA80.6/80.182.366.193.274.0
OpenAI GPT82.1/81.487.470.391.375.1
BERT-Base84.6/83.490.571.293.579.6
BERT-Large86.7/85.992.772.194.982.1

BERT-Large 平均得分相较于 OpenAI GPT 提升了 7.0 个百分点。

SQuAD 问答

在 SQuAD v1.1 中,BERT-Large 单模型获得了 93.2 的 F1 分数,超越了人类水平(Human F1 91.2)。在更难的 SQuAD v2.0(允许无答案)上,同样取得了 83.1 的 SOTA 成绩。

消融实验

基于 BERT-Base 架构的消融实验验证了各设计的贡献:

  • 去掉 NSP:保留 MLM 但在 NLI 和 SQuAD 任务上性能显著下降,证明了预训练捕捉句子间关系的重要性。
  • 仅自左向右 + 去掉 NSP(类似 GPT 架构):所有任务性能全面崩盘,SQuAD F1 从 88.5 掉到 77.8。即使强行在微调时加上 BiLSTM 层,表现也远远落后于原生双向 BERT。
  • 铁证了"深度双向"架构是 BERT 成功的最核心因素

Feature-based 方案

论文 5.3 节证明,完全冻结 BERT 参数,仅将最后 4 层隐藏层输出拼接起来作为特征输入给 BiLSTM,在 NER 任务上能达到 96.1 的 F1,仅比全量微调(96.4)低 0.3。这意味着 BERT 也可以作为强大的静态特征提取引擎。

总结

BERT 通过深度双向 Transformer 编码器和两个创新的预训练任务(MLM + NSP),极大提升了语言理解能力。其"预训练 + 微调"范式成为后续 NLP 模型的标准流程,深刻影响了 RoBERTa、ALBERT、XLNet 等后续工作的发展。

代码实战

完整的 BERT 代码实现(MLM + NSP 预训练 → 情感分类微调),包含源代码实现与 nn.TransformerEncoder 简洁实现两种方式的对比:

Open In Colab

输入表示

BERT 的输入向量由 Token Embedding、Segment Embedding、Position Embedding 三者相加,经过 LayerNorm + Dropout:

class BERTEmbedding(nn.Module):
    def __init__(self, vocab_size, d_model, max_len, dropout):
        super().__init__()
        self.token_embed    = nn.Embedding(vocab_size, d_model)
        self.segment_embed  = nn.Embedding(2, d_model)
        self.position_embed = nn.Embedding(max_len, d_model)
        self.norm = nn.LayerNorm(d_model)
        self.dropout = nn.Dropout(dropout)
 
    def forward(self, input_ids, segment_ids):
        seq_len = input_ids.size(1)
        pos_ids = torch.arange(seq_len, device=input_ids.device).unsqueeze(0)
        x = self.token_embed(input_ids)
        x = x + self.segment_embed(segment_ids)
        x = x + self.position_embed(pos_ids)
        return self.dropout(self.norm(x))

Multi-Head Self-Attention + Encoder Block

BERT 使用 Self-Attention 实现双向上下文编码,配合 GELU 激活的 FFN 和 Post-LN 残差连接:

class MultiHeadAttention(nn.Module):
    def __init__(self, d_model, num_heads, dropout):
        super().__init__()
        self.d_k = d_model // num_heads
        self.num_heads = num_heads
        self.W_q = nn.Linear(d_model, d_model)
        self.W_k = nn.Linear(d_model, d_model)
        self.W_v = nn.Linear(d_model, d_model)
        self.W_o = nn.Linear(d_model, d_model)
        self.dropout = nn.Dropout(dropout)
 
    def forward(self, x, attention_mask=None):
        B, S, _ = x.shape
        Q = self.W_q(x).view(B, S, self.num_heads, self.d_k).transpose(1, 2)
        K = self.W_k(x).view(B, S, self.num_heads, self.d_k).transpose(1, 2)
        V = self.W_v(x).view(B, S, self.num_heads, self.d_k).transpose(1, 2)
        scores = torch.matmul(Q, K.transpose(-2, -1)) / (self.d_k ** 0.5)
        if attention_mask is not None:
            mask = attention_mask.unsqueeze(1).unsqueeze(2)
            scores = scores.masked_fill(mask == 0, float('-inf'))
        attn_w = self.dropout(torch.softmax(scores, dim=-1))
        ctx = torch.matmul(attn_w, V)
        ctx = ctx.transpose(1, 2).contiguous().view(B, S, -1)
        return self.W_o(ctx), attn_w
 
 
class TransformerBlock(nn.Module):
    def __init__(self, d_model, num_heads, d_ff, dropout):
        super().__init__()
        self.attn = MultiHeadAttention(d_model, num_heads, dropout)
        self.norm1 = nn.LayerNorm(d_model)
        self.norm2 = nn.LayerNorm(d_model)
        self.ffn = nn.Sequential(
            nn.Linear(d_model, d_ff),
            nn.GELU(),
            nn.Linear(d_ff, d_model),
            nn.Dropout(dropout),
        )
 
    def forward(self, x, attention_mask=None):
        attn_out, attn_w = self.attn(x, attention_mask)
        x = self.norm1(x + attn_out)
        x = self.norm2(x + self.ffn(x))
        return x, attn_w

预训练与微调任务头

预训练和微调共享 BERT 编码器,仅更换任务头:

class BERTForPretraining(nn.Module):
    """预训练:MLM + NSP 双任务头"""
    def __init__(self, bert, vocab_size):
        super().__init__()
        self.bert = bert
        d = bert.d_model
        self.mlm_head = nn.Sequential(
            nn.Linear(d, d), nn.GELU(), nn.LayerNorm(d),
            nn.Linear(d, vocab_size),
        )
        self.nsp_head = nn.Linear(d, 2)
 
    def forward(self, input_ids, segment_ids, attention_mask):
        h = self.bert.encode(input_ids, segment_ids, attention_mask)
        return self.mlm_head(h), self.nsp_head(h[:, 0])
 
 
class BERTClassifier(nn.Module):
    """微调:[CLS] -> 分类"""
    def __init__(self, bert, num_classes):
        super().__init__()
        self.bert = bert
        self.classifier = nn.Linear(bert.d_model, num_classes)
 
    def forward(self, input_ids, segment_ids, attention_mask):
        h = self.bert.encode(input_ids, segment_ids, attention_mask)
        return self.classifier(h[:, 0])

参考文献