查看全课大纲(第 26 / 40 节)
- 1.从规则到学习:深度学习解决什么问题
- 2.表示学习与端到端训练
- 3.张量、计算图与自动微分
- 4.训练三要素:数据、模型、目标函数
- 5.从感知机到多层感知机
- 6.反向传播:手推一个两层网络
- 7.激活函数与初始化
- 8.优化器:SGD → Momentum → Adam
- 9.正则化:权重衰减、Dropout、BN/LN
- 10.卷积的直觉与参数共享
- 11.池化、感受野与下采样
- 12.经典网络演进:LeNet → AlexNet → VGG → ResNet
- 13.残差连接与归一化为什么有效
- 14.迁移学习与特征可视化
- 15.RNN 与 BPTT
- 16.LSTM/GRU:门控如何缓解梯度问题
- 17.Seq2Seq 与编码器-解码器
- 18.注意力前史:Bahdanau 加性注意力
- 19.Self-Attention:QKV 与缩放点积
- 20.多头注意力:并行的多种关系
- 21.位置编码:正弦与可学习
- 22.残差、LayerNorm 与前馈层
- 23.从零实现一个最小 Transformer block
- 24.训练稳定性、复杂度与常见误区
- 25.自监督与语言建模目标
- 26.BERT:掩码语言模型与双向编码
- 27.GPT:自回归生成与缩放定律
- 28.指令微调与对齐:SFT → RLHF/DPO
- 29.参数高效微调:LoRA 与适配器
- 30.自编码器与 VAE
- 31.GAN:对抗训练与模式崩塌
- 32.扩散模型:前向加噪与反向去噪
- 33.扩散模型的应用与采样加速
- 34.对比学习与 CLIP
- 35.ViT:把图像当序列
- 36.多模态架构:对齐、融合与生成
- 37.能力边界、幻觉与评测
- 38.PyTorch 2.x 训练循环与混合精度
- 39.实验管理、评测与复现
- 40.推理部署与成本:量化/蒸馏概览
BERT:掩码语言模型与双向编码
小象实战讲义 · 深度学习基础:从神经网络到 Transformer
上一节我们厘清了自监督与两种语言建模目标,这一节深入这条线的里程碑——BERT。它用一个巧妙的预训练任务——掩码语言模型(Masked Language Model, MLM),把 Transformer 编码器训练成了双向语言理解器。学完本节,你将能回答:BERT 的输入怎么组织?MLM 到底怎么“遮盖”和“预测”?为什么 MLM 能实现双向编码?预训练好的 BERT 又怎么接到下游任务上?
先修:Transformer 编码器的结构(第 5 章 5.5 节)、自注意力与多头注意力(5.1、5.2 节)、交叉熵损失。后续:下一节讲 GPT 的自回归路线与缩放定律,再往后是指令微调对齐与 LoRA。
💡 核心导读
- BERT 的核心创新:提出掩码语言模型(MLM)作为预训练目标,使 Transformer 编码器能同时利用左右上下文,实现真正的双向编码。
- 统一的输入表示:通过
[CLS]和[SEP]等特殊标记,BERT 能够处理单句和句子对任务,形成统一的输入格式。 - 两阶段范式:BERT 遵循 预训练(在大量无标签文本上学习通用表示)→ 微调(在特定任务数据上更新所有权重)的范式,极大减少了为每个任务设计专门架构的需求。
- 模型架构:BERT 本质是一个多层双向 Transformer 编码器堆栈,其基础版本(
BERT_BASE)有 12 层,隐藏层维度 768,注意力头数 12,参数量约 1.1 亿。 - 微调的灵活性:通过替换或添加简单的输出层,预训练的 BERT 可以适配句子级(如分类)和词元级(如问答)的下游任务。
🧭 前置知识
- Transformer 编码器:理解其由自注意力层和前馈网络层堆叠而成的结构。
- 自注意力机制:掌握查询、键、值的计算以及缩放点积注意力。
- softmax 函数与交叉熵损失:用于将模型输出转换为概率分布并计算损失。
- 词嵌入:理解如何将离散的词 ID 映射为连续的向量表示。
从单向到双向:BERT 要解决什么问题?
在 BERT 之前,主流的预训练语言模型(如 OpenAI GPT)通常采用单向的语言建模目标,即根据上文预测下一个词。这种限制使得模型在编码每个词时,只能“看到”它左侧的上下文。这对于需要理解完整上下文的任务(如问答、句子关系判断)来说,是一个明显的瓶颈。
BERT(Bidirectional Encoder Representations from Transformers)的提出,正是为了打破这一限制。其核心思想是:通过一种允许模型同时“看到”左右两侧上下文的预训练任务,来训练一个深度双向的 Transformer 编码器。这个任务就是掩码语言模型(Masked Language Model, MLM)。
BERT 的输入与模型架构
统一的输入表示
BERT 的输入设计非常精巧,能够统一处理单句和句子对(如问答、蕴含)任务。输入序列由以下部分拼接而成:
[CLS]标记:位于每个输入序列的开头。该标记的最终隐藏状态常被用作整个序列的聚合表示,用于句子级分类任务。- 词元序列:可以是单个句子,也可以是两个句子(用
[SEP]分隔)。 [SEP]标记:用于分隔句子对中的两个句子。对于单句输入,也用于标记句子结束。- 位置编码与段编码:与原始 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)任务。具体步骤如下:
- 随机遮盖:在输入序列中,随机选择 15% 的词元进行遮盖。
- 遮盖策略:对于这 15% 的被选词元,并非全部替换为特殊的
[MASK]标记。论文中采用以下策略:- 80% 的概率替换为
[MASK]。 - 10% 的概率替换为一个随机的词元。
- 10% 的概率保持原词不变。
- 80% 的概率替换为
- 预测目标:模型的任务是基于被遮盖位置的双向上下文,预测出被遮盖位置的原始词 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 编码器。对于下游任务:
- 初始化:用预训练好的权重初始化任务模型。
- 构建任务头:根据任务类型(分类、问答、标注等),在 BERT 编码器之上添加一个简单的输出层(如一个线性分类器)。
- 端到端训练:使用下游任务的标注数据,对整个模型(包括 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)送入任务特定的输出层。 |
本章小结
- BERT 的核心是掩码语言模型(MLM),它通过随机遮盖部分输入词元并让模型基于双向上下文进行预测,实现了深度双向语言编码。
- BERT 的输入通过
[CLS]、[SEP]等特殊标记和段编码,统一了单句和句子对任务的表示格式。 - BERT 模型本质是一个多层双向 Transformer 编码器,其
BASE版本包含 12 层,隐藏维度 768,注意力头 12 个。 - BERT 采用预训练-微调两阶段范式。预训练联合优化 MLM 和下一句预测(NSP)任务;微调时,用预训练权重初始化模型,并针对下游任务添加简单的输出层进行端到端训练。
- MLM 的遮盖策略(80% MASK, 10% 随机词, 10% 原词)是关键设计,旨在缓解预训练(大量见到
[MASK])与微调(见到真实词)之间的不一致。
📋 行动清单
- 运行本节第一个代码块,多次执行(可修改随机种子),观察 MLM 三种遮盖策略的比例是否大致符合 8:1:1。
- 修改微调模拟代码中的
num_labels,将其改为 3(三分类),并调整对应的模拟标签,观察模型输出形状的变化。 - 查阅 PyTorch 文档中
nn.TransformerEncoder和nn.TransformerEncoderLayer的参数,了解如何传入src_key_padding_mask以实现对填充位置的掩码。
延伸阅读
- BERT: Pre-training of Deep Bidirectional Transformers for Language Understanding(Devlin et al., 2018,arXiv:1810.04805)—— 本节核心论文,详细阐述了 BERT 的动机、模型架构、预训练任务和实验。
- Attention Is All You Need(Vaswani et al., 2017,arXiv:1706.03762)—— Transformer 架构的原论文,是理解 BERT 编码器基础的前提。
—— 小象教研组