查看全课大纲(第 26 / 40 节)
  1. 1.从规则到学习:深度学习解决什么问题
  2. 2.表示学习与端到端训练
  3. 3.张量、计算图与自动微分
  4. 4.训练三要素:数据、模型、目标函数
  5. 5.从感知机到多层感知机
  6. 6.反向传播:手推一个两层网络
  7. 7.激活函数与初始化
  8. 8.优化器:SGD → Momentum → Adam
  9. 9.正则化:权重衰减、Dropout、BN/LN
  10. 10.卷积的直觉与参数共享
  11. 11.池化、感受野与下采样
  12. 12.经典网络演进:LeNet → AlexNet → VGG → ResNet
  13. 13.残差连接与归一化为什么有效
  14. 14.迁移学习与特征可视化
  15. 15.RNN 与 BPTT
  16. 16.LSTM/GRU:门控如何缓解梯度问题
  17. 17.Seq2Seq 与编码器-解码器
  18. 18.注意力前史:Bahdanau 加性注意力
  19. 19.Self-Attention:QKV 与缩放点积
  20. 20.多头注意力:并行的多种关系
  21. 21.位置编码:正弦与可学习
  22. 22.残差、LayerNorm 与前馈层
  23. 23.从零实现一个最小 Transformer block
  24. 24.训练稳定性、复杂度与常见误区
  25. 25.自监督与语言建模目标
  26. 26.BERT:掩码语言模型与双向编码
  27. 27.GPT:自回归生成与缩放定律
  28. 28.指令微调与对齐:SFT → RLHF/DPO
  29. 29.参数高效微调:LoRA 与适配器
  30. 30.自编码器与 VAE
  31. 31.GAN:对抗训练与模式崩塌
  32. 32.扩散模型:前向加噪与反向去噪
  33. 33.扩散模型的应用与采样加速
  34. 34.对比学习与 CLIP
  35. 35.ViT:把图像当序列
  36. 36.多模态架构:对齐、融合与生成
  37. 37.能力边界、幻觉与评测
  38. 38.PyTorch 2.x 训练循环与混合精度
  39. 39.实验管理、评测与复现
  40. 40.推理部署与成本:量化/蒸馏概览

BERT:掩码语言模型与双向编码

小象实战讲义 · 深度学习基础:从神经网络到 Transformer

上一节我们厘清了自监督与两种语言建模目标,这一节深入这条线的里程碑——BERT。它用一个巧妙的预训练任务——掩码语言模型(Masked Language Model, MLM),把 Transformer 编码器训练成了双向语言理解器。学完本节,你将能回答:BERT 的输入怎么组织?MLM 到底怎么“遮盖”和“预测”?为什么 MLM 能实现双向编码?预训练好的 BERT 又怎么接到下游任务上?

先修:Transformer 编码器的结构(第 5 章 5.5 节)、自注意力与多头注意力(5.1、5.2 节)、交叉熵损失。后续:下一节讲 GPT 的自回归路线与缩放定律,再往后是指令微调对齐与 LoRA。

💡 核心导读

  1. BERT 的核心创新:提出掩码语言模型(MLM)作为预训练目标,使 Transformer 编码器能同时利用左右上下文,实现真正的双向编码。
  2. 统一的输入表示:通过 [CLS] 和 [SEP] 等特殊标记,BERT 能够处理单句和句子对任务,形成统一的输入格式。
  3. 两阶段范式:BERT 遵循 预训练(在大量无标签文本上学习通用表示)→ 微调(在特定任务数据上更新所有权重)的范式,极大减少了为每个任务设计专门架构的需求。
  4. 模型架构:BERT 本质是一个多层双向 Transformer 编码器堆栈,其基础版本(BERT_BASE)有 12 层,隐藏层维度 768,注意力头数 12,参数量约 1.1 亿。
  5. 微调的灵活性:通过替换或添加简单的输出层,预训练的 BERT 可以适配句子级(如分类)和词元级(如问答)的下游任务。

🧭 前置知识

  1. Transformer 编码器:理解其由自注意力层和前馈网络层堆叠而成的结构。
  2. 自注意力机制:掌握查询、键、值的计算以及缩放点积注意力。
  3. softmax 函数与交叉熵损失:用于将模型输出转换为概率分布并计算损失。
  4. 词嵌入:理解如何将离散的词 ID 映射为连续的向量表示。

从单向到双向:BERT 要解决什么问题?

在 BERT 之前,主流的预训练语言模型(如 OpenAI GPT)通常采用单向的语言建模目标,即根据上文预测下一个词。这种限制使得模型在编码每个词时,只能“看到”它左侧的上下文。这对于需要理解完整上下文的任务(如问答、句子关系判断)来说,是一个明显的瓶颈。

BERT(Bidirectional Encoder Representations from Transformers)的提出,正是为了打破这一限制。其核心思想是:通过一种允许模型同时“看到”左右两侧上下文的预训练任务,来训练一个深度双向的 Transformer 编码器。这个任务就是掩码语言模型(Masked Language Model, MLM)。

BERT 的输入与模型架构

统一的输入表示

BERT 的输入设计非常精巧,能够统一处理单句和句子对(如问答、蕴含)任务。输入序列由以下部分拼接而成:

  1. [CLS] 标记:位于每个输入序列的开头。该标记的最终隐藏状态常被用作整个序列的聚合表示,用于句子级分类任务。
  2. 词元序列:可以是单个句子,也可以是两个句子(用 [SEP] 分隔)。
  3. [SEP] 标记:用于分隔句子对中的两个句子。对于单句输入,也用于标记句子结束。
  4. 位置编码与段编码:与原始 Transformer 一样,BERT 也使用位置编码来注入序列顺序信息。此外,它还引入了段编码(Segment Embeddings)来区分句子 A 和句子 B。

一个输入序列的构成如下所示(用文本示意):

[CLS] 我 喜欢 深度 学习 [SEP] 它 非常 有趣 [SEP]

对应的输入张量包括:

  • input_ids: 词 ID 序列。
  • token_type_ids: 段 ID (0 代表句子 A,1 代表句子 B)。
  • attention_mask: 注意力掩码 (1 代表真实词元,0 代表填充部分)。

模型架构:双向 Transformer 编码器

BERT 的模型架构是一个多层双向 Transformer 编码器(BERT: Pre-training of Deep Bidirectional Transformers for Language Understanding(Devlin et al., 2018,arXiv:1810.04805))。论文中主要介绍了两种尺寸的模型:

  • BERT_BASE: L=12(层数),H=768(隐藏层维度),A=12(注意力头数),总参数量约 1.1 亿。此配置被设计为与 OpenAI GPT 参数量相当,以便公平对比。
  • BERT_LARGE: L=24, H=1024, A=16,总参数量约 3.4 亿。

关键区别在于,BERT 编码器中的自注意力层是双向的,每个位置都可以关注序列中的所有其他位置(包括未来位置),而 GPT 使用的是带掩码的自注意力,只能关注当前位置及之前的位置。

核心预训练任务:掩码语言模型 (MLM)

MLM 是 BERT 实现双向编码的“魔法”。其灵感来源于完形填空(Cloze)任务。具体步骤如下:

  1. 随机遮盖:在输入序列中,随机选择 15% 的词元进行遮盖。
  2. 遮盖策略:对于这 15% 的被选词元,并非全部替换为特殊的 [MASK] 标记。论文中采用以下策略:
    • 80% 的概率替换为 [MASK]。
    • 10% 的概率替换为一个随机的词元。
    • 10% 的概率保持原词不变。
  3. 预测目标:模型的任务是基于被遮盖位置的双向上下文,预测出被遮盖位置的原始词 ID。

这种策略的巧妙之处在于:

  • 80% 的 [MASK]:让模型学会根据上下文推断被掩盖的信息。
  • 10% 的随机词:防止模型过度依赖 [MASK] 标记,增强其鲁棒性。
  • 10% 的原词:让表示偏向真实观察到的词元——微调时输入里没有 [MASK],这一步能缩小预训练与微调之间的分布差异。

下面我们用代码模拟 MLM 的数据准备过程,并观察遮盖策略的效果。

import torch
import torch.nn as nn
import random

torch.manual_seed(0)
random.seed(0)

def prepare_mlm_inputs_paper_strategy(input_ids, mask_token_id=103, vocab_size=30522):
    """
    模拟 BERT 论文中的 MLM 遮盖策略。
    input_ids: (batch, seq_len)
    返回: masked_input_ids, mlm_labels (被遮盖位置的原始ID,未遮盖位置为-100)
    """
    batch_size, seq_len = input_ids.shape
    mlm_labels = input_ids.clone() # 用于存储需要预测的原始ID
    masked_input_ids = input_ids.clone()
    
    # 1. 确定哪些位置需要被处理(15%)
    # 真实 BERT 一般不遮盖 [CLS]、[SEP] 等特殊标记;
    # 本示例为简化,假设包括特殊标记在内的所有位置都参与 15% 抽样
    prob_matrix = torch.full((batch_size, seq_len), 0.15)
    masked_positions = torch.bernoulli(prob_matrix).bool() # True 表示该位置被选中
    
    # 初始化标签:只有被选中的位置才需要计算损失,其余置为 -100(PyTorch 交叉熵忽略的索引)
    mlm_labels[~masked_positions] = -100
    
    # 2. 对被选中的位置应用论文策略
    for i in range(batch_size):
        for j in range(seq_len):
            if masked_positions[i, j]:
                original_id = input_ids[i, j].item()
                rand = random.random()
                if rand < 0.8:
                    # 80% -> 替换为 [MASK]
                    masked_input_ids[i, j] = mask_token_id
                elif rand < 0.9:
                    # 10% -> 替换为随机词
                    masked_input_ids[i, j] = random.randint(0, vocab_size - 1)
                else:
                    # 10% -> 保持原词不变
                    masked_input_ids[i, j] = original_id
                    # 注意:即使保持原词,模型仍需预测它(标签不是-100)
    return masked_input_ids, mlm_labels

# 模拟一个批次的数据
# 假设词表ID: 101=[CLS], 102=[SEP], 103=[MASK], 我=2001, 喜欢=2002, 深度=2003, 学习=2004
input_ids = torch.tensor([
    [101, 2001, 2002, 2003, 2004, 102]  # [CLS] 我 喜欢 深度 学习 [SEP]
])
print("原始 input_ids:", input_ids.tolist())

masked_input, mlm_labels = prepare_mlm_inputs_paper_strategy(input_ids, mask_token_id=103, vocab_size=10000)
print("遮盖后 input_ids:", masked_input.tolist())
print("MLM 标签 (原始ID,-100表示忽略):", mlm_labels.tolist())

# 统计一次运行中三种策略的比例(由于随机性,每次运行结果不同)
masked_positions = (mlm_labels != -100)
selected_tokens = input_ids[masked_positions]
masked_tokens = masked_input[masked_positions]
mask_count = (masked_tokens == 103).sum().item()
random_count = ((masked_tokens != selected_tokens) & (masked_tokens != 103)).sum().item()
same_count = (masked_tokens == selected_tokens).sum().item()
total = masked_positions.sum().item()
print(f"\n本次遮盖统计(共{total}个被选位置):")
print(f"  替换为[MASK]: {mask_count} ({mask_count/total:.1%})")
print(f"  替换为随机词: {random_count} ({random_count/total:.1%})")
print(f"  保持原词不变: {same_count} ({same_count/total:.1%})")
原始 input_ids: [[101, 2001, 2002, 2003, 2004, 102]]
遮盖后 input_ids: [[101, 2001, 6890, 103, 2004, 102]]
MLM 标签 (原始ID,-100表示忽略): [[-100, -100, 2002, 2003, -100, -100]]

本次遮盖统计(共2个被选位置):
  替换为[MASK]: 1 (50.0%)
  替换为随机词: 1 (50.0%)
  保持原词不变: 0 (0.0%)

预训练与微调流程

BERT 的训练分为两个阶段。

1. 预训练

BERT 同时在两个无监督任务上进行预训练:

  • 任务 #1: 掩码语言模型 (MLM):如上所述,是核心任务。
  • 任务 #2: 下一句预测 (NSP):模型接收句子对 (A, B) 作为输入,预测 B 是否是 A 的下一句。这有助于模型理解句子间关系。正例从文档中连续抽取,负例随机抽取另一句作为 B。

预训练的目标是同时最小化 MLM 和 NSP 的损失之和。

2. 微调

预训练完成后,得到一个富含语言知识的 Transformer 编码器。对于下游任务:

  1. 初始化:用预训练好的权重初始化任务模型。
  2. 构建任务头:根据任务类型(分类、问答、标注等),在 BERT 编码器之上添加一个简单的输出层(如一个线性分类器)。
  3. 端到端训练:使用下游任务的标注数据,对整个模型(包括 BERT 编码器和任务头)的所有参数进行微调。

这种微调范式非常高效,通常只需要很少的标注数据就能取得优异效果。下面我们模拟一个单句分类任务的微调过程。

import torch
import torch.nn as nn
import torch.nn.functional as F

torch.manual_seed(0)

class SimulatedBERTForSequenceClassification(nn.Module):
    """
    一个极简的模拟,展示 BERT 用于文本分类的微调架构。
    """
    def __init__(self, vocab_size, hidden_dim, num_labels, num_layers=2):
        super().__init__()
        # 1. 词嵌入层 (模拟预训练好的嵌入)
        self.embeddings = nn.Embedding(vocab_size, hidden_dim)
        # 2. 编码器层:使用 PyTorch 内置的 TransformerEncoder 模拟 BERT 主体
        encoder_layer = nn.TransformerEncoderLayer(
            d_model=hidden_dim,
            nhead=2, # 注意力头数
            dim_feedforward=hidden_dim*4,
            batch_first=True,
            dropout=0.0 # 为演示关闭dropout
        )
        self.encoder = nn.TransformerEncoder(encoder_layer, num_layers=num_layers)
        # 3. 分类头:通常接在 [CLS] 标记的表示上
        self.classifier = nn.Linear(hidden_dim, num_labels)
        
    def forward(self, input_ids, attention_mask=None):
        # 获取嵌入
        embeddings = self.embeddings(input_ids) # (batch, seq_len, hidden_dim)
        
        # 通过编码器(模拟 BERT)
        # 注意:真实 BERT 会加入位置编码和段编码,这里简化
        encoder_outputs = self.encoder(embeddings) # (batch, seq_len, hidden_dim)
        
        # 取 [CLS] 标记(假设在索引0)的表示作为整个序列的聚合
        cls_output = encoder_outputs[:, 0, :] # (batch, hidden_dim)
        
        # 分类
        logits = self.classifier(cls_output) # (batch, num_labels)
        return logits

# 模拟参数
vocab_size = 10000
hidden_dim = 64
num_labels = 2 # 二分类
batch_size = 2
seq_len = 8

# 初始化模型(模拟加载了预训练权重的 BERT)
model = SimulatedBERTForSequenceClassification(vocab_size, hidden_dim, num_labels)

# 模拟输入数据
input_ids = torch.randint(0, vocab_size, (batch_size, seq_len))
# 假设第一个 token 是 [CLS]
input_ids[:, 0] = 101
print("模拟输入 input_ids 形状:", input_ids.shape)

# 前向传播
logits = model(input_ids)
print("模型输出 logits 形状:", logits.shape)
print("logits 值(取两位小数):", [[round(v.item(), 2) for v in sample] for sample in logits])

# 模拟一次微调步骤
labels = torch.tensor([0, 1]) # 真实标签
loss_fn = nn.CrossEntropyLoss()
loss = loss_fn(logits, labels)
print(f"\n计算出的交叉熵损失: {loss.item():.4f}")

# 模拟反向传播(计算梯度)
loss.backward()
# 检查部分参数的梯度是否存在(表示可微调)
print(f"编码器第一层自注意力 QKV 融合投影权重(in_proj_weight)梯度范数: {model.encoder.layers[0].self_attn.in_proj_weight.grad.norm().item():.4f}")
print(f"分类头权重梯度范数: {model.classifier.weight.grad.norm().item():.4f}")
模拟输入 input_ids 形状: torch.Size([2, 8])
模型输出 logits 形状: torch.Size([2, 2])
logits 值(取两位小数): [[0.59, -0.27], [0.71, -0.23]]

计算出的交叉熵损失: 0.8085
编码器第一层自注意力 QKV 融合投影权重(in_proj_weight)梯度范数: 0.5610
分类头权重梯度范数: 2.5632

📝 动手练一练

题目:在 MLM 的遮盖策略中,如果我们将“80% MASK, 10% 随机词, 10% 原词”的策略改为“100% 替换为 [MASK]”,可能会带来什么问题?请通过修改上面 prepare_mlm_inputs_paper_strategy 函数的逻辑,模拟 100% MASK 策略,并思考其潜在缺陷。

👉 点击查看参考答案

思路:100% 使用 [MASK] 会使得预训练和微调之间出现不一致。在预训练时,模型只见过被 [MASK] 遮盖的句子,但在微调时,输入的是真实的、未被遮盖的句子。这种差异可能导致模型性能下降。论文中的混合策略正是为了缓解这个问题。

代码修改与模拟:

import torch
import random

torch.manual_seed(0)
random.seed(0)

def prepare_mlm_inputs_all_mask(input_ids, mask_token_id=103):
    """100% 替换为 [MASK] 的策略"""
    batch_size, seq_len = input_ids.shape
    mlm_labels = input_ids.clone()
    masked_input_ids = input_ids.clone()
    
    prob_matrix = torch.full((batch_size, seq_len), 0.15)
    masked_positions = torch.bernoulli(prob_matrix).bool()
    mlm_labels[~masked_positions] = -100
    
    # 简单粗暴:所有被选中的位置都替换为 [MASK]
    masked_input_ids[masked_positions] = mask_token_id
    return masked_input_ids, mlm_labels

# 使用相同输入对比
input_ids = torch.tensor([[101, 2001, 2002, 2003, 2004, 102]])
masked_all_mask, labels_all_mask = prepare_mlm_inputs_all_mask(input_ids)
print("100% MASK 策略结果:")
print("遮盖后 input_ids:", masked_all_mask.tolist())
print("MLM 标签:", labels_all_mask.tolist())
print("被遮盖位置是否全是103([MASK])?", torch.all(masked_all_mask[labels_all_mask != -100] == 103))
100% MASK 策略结果:
遮盖后 input_ids: [[101, 2001, 103, 103, 2004, 102]]
MLM 标签: [[-100, -100, 2002, 2003, -100, -100]]
被遮盖位置是否全是103([MASK])? tensor(True)

常见错误

错误理解或写法现象或报错原因正确做法
在微调时冻结了 BERT 的所有参数,只训练分类头。模型收敛慢,性能可能低于预期。BERT 预训练获得的是通用语言表示,需要在特定任务数据上进行适度调整才能达到最佳效果。全参数微调是标准做法。使用 model.parameters() 将所有参数传递给优化器,进行端到端微调。对于超大模型或极小数据,可考虑只微调最后几层。
处理序列时,未正确设置 attention_mask 以忽略填充部分。模型性能下降,因为自注意力机制会“关注”无意义的填充符号,干扰表示学习。Transformer 的自注意力是全局的,默认会处理序列中的所有位置。在 softmax 之前,把填充位置对应的注意力分数(logits)加上一个极大的负值(如 -1e9),使其 softmax 后的权重接近 0。PyTorch 的 nn.Transformer 相关模块通常支持传入 src_key_padding_mask。
误以为 BERT 的 MLM 是简单地“挖空”并预测。忽略了 10% 随机词和 10% 原词的策略,无法理解其缓解预训练-微调差异的设计初衷。如果只用 [MASK],模型在微调时遇到真实词元可能会不知所措。深入理解 MLM 的三种遮盖策略及其比例(80/10/10),这是 BERT 成功的关键设计之一。
将 [CLS] 标记的表示直接用于计算所有词的损失(如命名实体识别)。序列标注任务效果差。[CLS] 是用于序列级分类的聚合表示。对于词元级任务,应该使用每个词元对应的最终隐藏层输出。对于问答、命名实体识别等任务,取 BERT 编码器输出的所有词元表示(sequence_output)送入任务特定的输出层。

本章小结

  1. BERT 的核心是掩码语言模型(MLM),它通过随机遮盖部分输入词元并让模型基于双向上下文进行预测,实现了深度双向语言编码。
  2. BERT 的输入通过 [CLS]、[SEP] 等特殊标记和段编码,统一了单句和句子对任务的表示格式。
  3. BERT 模型本质是一个多层双向 Transformer 编码器,其 BASE 版本包含 12 层,隐藏维度 768,注意力头 12 个。
  4. BERT 采用预训练-微调两阶段范式。预训练联合优化 MLM 和下一句预测(NSP)任务;微调时,用预训练权重初始化模型,并针对下游任务添加简单的输出层进行端到端训练。
  5. MLM 的遮盖策略(80% MASK, 10% 随机词, 10% 原词)是关键设计,旨在缓解预训练(大量见到 [MASK])与微调(见到真实词)之间的不一致。

📋 行动清单

  • 运行本节第一个代码块,多次执行(可修改随机种子),观察 MLM 三种遮盖策略的比例是否大致符合 8:1:1。
  • 修改微调模拟代码中的 num_labels,将其改为 3(三分类),并调整对应的模拟标签,观察模型输出形状的变化。
  • 查阅 PyTorch 文档中 nn.TransformerEncoder 和 nn.TransformerEncoderLayer 的参数,了解如何传入 src_key_padding_mask 以实现对填充位置的掩码。

延伸阅读

  1. BERT: Pre-training of Deep Bidirectional Transformers for Language Understanding(Devlin et al., 2018,arXiv:1810.04805)—— 本节核心论文,详细阐述了 BERT 的动机、模型架构、预训练任务和实验。
  2. Attention Is All You Need(Vaswani et al., 2017,arXiv:1706.03762)—— Transformer 架构的原论文,是理解 BERT 编码器基础的前提。

—— 小象教研组