PyTorch 2.x 深度学习专题【左扬精讲】—— BERT系列解析:掩码语言模型(MLM)与下一句预测(NSP)的预训练原理
PyTorch 2.x 深度学习专题【左扬精讲】—— BERT系列解析:掩码语言模型(MLM)与下一句预测(NSP)的预训练原理
BERT (2018) BERT: Pre-training of Deep Bidirectional Transformers for Language Understanding ← 双向Transformer Encoder的开山之作
RoBERTa (2019) RoBERTa: A Robustly Optimized BERT Pretraining Approach ← 去掉NSP,增大训练数据,效果超越BERT
ALBERT (2019) ALBERT: A Lite BERT for Self-supervised Learning of Language Representations ← 跨层参数共享+SOP,轻量化BERT
BERT RoBERTa ALBERT Masked Language Model Next Sentence Prediction Transformer Encoder
本文学习重点
- 必须掌握
- MLM(掩码语言模型)的机制:15%掩码率、80/10/10策略、WordPiece分词
- NSP(下一句预测)的机制:50%正负样本、IsNext/NotNext二分类
- BERT的Transformer Encoder架构:双向注意力 vs GPT的单向因果注意力
- BERT微调(Fine-tuning)阶段的任务适配方式
- 需要理解
- 为什么BERT选择Encoder而不是Decoder
- RoBERTa去掉NSP后效果更好的原因
- ALBERT的跨层参数共享(Cross-layer Parameter Sharing)和SOP(Sentence Order Prediction)
- MLM和NSP各自的局限性及后续改进方向
目录导航
一、BERT的核心架构:双向Transformer Encoder
What — BERT的核心架构是什么?
BERT("BERT: Pre-training of Deep Bidirectional Transformers for Language Understanding",Devlin et al., 2018)使用 Transformer Encoder 作为基础架构。与GPT使用 Decoder 不同,BERT的每一层都使用双向自注意力(Bidirectional Self-Attention):序列中每个位置可以同时关注左边和右边的所有位置。
BERT有两个规模:BERT-Base(110M参数,12层,768维,16头)和BERT-Large(340M参数,24层,1024维,16头)。训练语料为 BooksCorpus(800M词)和 English Wikipedia(2.5B词),共约33亿token。
Why — 为什么要选择Transformer Encoder而不是Decoder?
问题一:BERT的目标是理解,不是生成。
BERT的核心定位是自然语言理解(NLU)任务:文本分类、问答、序列标注等。这类任务需要双向上下文才能做出准确判断。例如在情感分类中,理解 "这部电影太差了" 需要同时看到 "这部电影" 和 "太差了" 才能判断是负面情感。
Decoder(单向因果注意力)的设计是为了自回归生成:每个位置只能看到前面的token,无法直接获取右侧信息。如果用Decoder做理解任务,需要分别运行正向和反向两个模型才能获取双向上下文,计算代价翻倍。
问题二:双向注意力是BERT预训练MLM的前提。
MLM的机制是在输入中随机遮盖一些token,让模型预测被遮盖的内容。如果注意力是单向的,模型只能看到被遮盖token左侧的信息(右侧不可见),预测精度大幅下降。双向注意力使得每个被遮盖token都能看到完整上下文,从而预测更准确。
没有Transformer Encoder会发生什么?
- 无法高效实现双向上下文建模,传统方法(BiLSTM等)需要分别处理前向和后向,效率和效果都不如Transformer
- MLM预训练目标无法发挥最大效果,理解任务性能大幅下降
- 无法构建统一的预训练-微调范式,每个NLP任务仍需独立设计模型
BERT-Base的核心参数配置,以及与GPT的架构对比:
# BERT-Base 核心配置参数
bert_base_config = {
"vocab_size": 30522, # 词表大小(WordPiece分词,约3万token)
"hidden_size": 768, # 隐藏层维度(每个token的向量表示维度)
"num_hidden_layers": 12, # Transformer Encoder层数
"num_attention_heads": 16, # 注意力头数(每头 768/16=48 维)
"intermediate_size": 3072, # FFN内层维度(通常是4倍,即768*4=3072)
"hidden_act": "gelu", # 激活函数为GELU
"max_position_embeddings": 512, # 最大序列长度
"type_vocab_size": 2, # Segment Embedding词汇表大小(A/B句)
"hidden_dropout_prob": 0.1, # 隐藏层dropout率
"attention_probs_dropout_prob": 0.1, # 注意力dropout率
}
# 总参数量:约 110M
# BERT-Large 配置(对照)
bert_large_config = {
"hidden_size": 1024, # 隐藏层维度翻倍
"num_hidden_layers": 24, # 层数翻倍
"num_attention_heads": 16, # 16头(每头 1024/16=64 维)
"intermediate_size": 4096, # FFN内层4倍(4096)
}
# 总参数量:约 340M
# ============ 注意力机制对比:BERT vs GPT ============
# BERT Encoder(双向注意力):
# 每个位置 i 可以同时 attend to 所有其他位置 j(包括 i 左右两侧)
# attention_score(i, j) = Q_i · K_j^T / sqrt(d_k),对所有 j 求和
# 特点:双向可见 -> 适合理解任务
# GPT-2 Decoder(单向因果注意力):
# 每个位置 i 只能 attend to 位置 j <= i(不能看右侧)
# attention_mask将 j > i 的位置设为 -inf,softmax后权重归零
# 特点:单向 -> 适合生成任务
# 对比示例:输入 "今天天气真好"
# BERT注意力:位置"天"可以看到"气""真""好",预测更准确
# GPT注意力:位置"天"只能看到"今""天",无法获取右侧信息
BERT的注意力是双向的,这使得每个token的表示都能融合左右两侧的上下文信息。这是BERT在理解任务上远优于GPT的根本原因。
第一节小结
- 架构选择:Transformer Encoder(双向自注意力),BERT-Base 110M / BERT-Large 340M
- 双向注意力:每个位置同时看到左右两侧的所有token,适合理解任务
- 预训练目标:MLM(掩码语言建模)+ NSP(下一句预测)
- 训练数据:BooksCorpus(800M词)+ Wikipedia(2.5B词),共约33亿token
二、预训练任务一:掩码语言模型(MLM)
What — MLM是如何工作的?
MLM(Masked Language Model,掩码语言模型)是BERT的第一个预训练目标,灵感来自"完形填空"(Cloze)任务。其核心做法是:随机遮盖输入序列中15%的token,然后让模型预测被遮盖位置的真实token。
与传统的语言建模(GPT的自回归方式:从左到右依次预测)不同,MLM可以同时利用被遮盖token左右两侧的上下文进行预测,因此被称为"双向上下文建模"。
Why — 为什么要用MLM而不是标准的自回归语言建模?
问题一:自回归语言建模只能建模单向上下文。
标准的语言建模(CLM,Causal Language Modeling)是自回归的:预测第i个token时,只能看到第1到i-1个token。这是生成任务所需的(生成时只能依赖已生成的内容),但对于语言理解任务而言是严重的限制。例如在句子"The chef who worked at the restaurant closed the ___"中,预测"_"需要同时看到左右两侧的上下文(左侧的"restaurant"和右侧的"closed"),单向模型无法做到。
问题二:MLM通过随机遮盖实现双向上下文建模。
MLM的设计绕过了自回归的限制:通过在输入阶段遮盖掉部分token,模型在预测被遮盖位置时天然可以看到双向上下文。这种"遮盖然后预测"的范式使得BERT能够学习到深层的语义表示。
没有MLM会发生什么?
- 模型只能学到单向语义表示,理解任务的性能大幅下降
- 无法利用右侧上下文信息,语义理解不完整
- 很多NLP任务(如问答、关系抽取)需要双向上下文,模型能力严重受限
MLM的具体实现细节:掩码率、替换策略、以及代码演示:
# ============ MLM 掩码策略详解 ============
# BERT原始论文的掩码策略(15%掩码率):
# 从输入序列中随机选择15%的token位置进行掩码
# 示例:"[CLS] The man went to [MASK] store [SEP]"
# ^^^^^^^^^^^^^^^^^^^^^^^^
# 15%掩码位置(这里选中了"the"和"store")
# 80/10/10替换策略(BERT原文):
# 对每个被掩码的位置,以以下概率选择替换方式:
# - 80%:替换为 [MASK] token(直接遮盖)
# - 10%:替换为随机token(增加鲁棒性)
# - 10%:保持原token不变(减少预训练-微调不匹配)
# 为什么需要80/10/10分解?
# 1. 如果100%用[MASK]:微调阶段没有[MASK] token,导致预训练-微调不匹配
# 2. 加入10%随机替换:迫使模型不仅依靠上下文,还要学会处理噪声
# 3. 保留10%原token:减少[MASK]标记对模型的影响
# ============ MLM训练过程(代码演示)============
import random
def apply_mlm_masking(input_tokens, mask_token="[MASK]", vocab=list("abcdefghijklmnopqrstuvwxyz")):
# 输入:tokenized的词序列
# 输出:(masked_tokens, masked_positions, original_tokens)
tokens = input_tokens.copy()
num_tokens = len(tokens)
num_to_mask = max(1, int(num_tokens * 0.15)) # 15%掩码率
masked_positions = random.sample(range(num_tokens), num_to_mask) # 随机选位置
original_tokens = []
for pos in masked_positions:
original_tokens.append(tokens[pos]) # 保存原始token用于计算损失
rand = random.random()
if rand < 0.8:
tokens[pos] = mask_token # 80%:替换为[MASK]
elif rand < 0.9:
tokens[pos] = random.choice(vocab) # 10%:替换为随机token
# else: tokens[pos]保持不变 # 10%:保持原token
return tokens, masked_positions, original_tokens
# 示例
input_seq = ["The", "man", "walked", "to", "the", "store"]
masked_seq, positions, originals = apply_mlm_masking(input_seq)
print(f"原始: {input_seq}")
print(f"掩码后: {masked_seq}") # 例如:["The", "[MASK]", "walked", "to", "the", "store"]
print(f"位置: {positions}, 原始: {originals}") # 模型需要预测 originals
# ============ 训练时的损失函数 ============
# MLM损失 = 所有被掩码位置的交叉熵损失之和
# L_MLM = -sum_{i in masked_positions} log P(original_token_i | context)
# 损失只在被掩码位置计算,非掩码位置的loss不参与反向传播
# BERT论文训练超参数:
# - 预训练步数:1,000,000步(1M steps)
# - Batch size:256 sequences(约128K tokens/batch)
# - 学习率:1e-4(Base)/ 5e-5(Large)
# - Warmup:10,000步(学习率先升后降)
# - 训练设备:BERT-Base在16块TPU芯片上训练4天
MLM的几个关键理解点:
1. 15%掩码率是精度和效率的平衡:太低(5%)训练效率低(每个step只有很少token贡献损失);太高(50%)会破坏上下文的完整性,预测变得困难。
2. 80/10/10的分解是经验性的:论文通过消融实验发现这个比例效果最好,但不同任务可能存在更优比例。
3. WordPiece分词使得被掩码的常常是subword(如"##ing")而不是完整单词,降低了预测难度。
第二节小结
- 掩码率:15%,即每100个token中随机选择15个进行掩码
- 80/10/10策略:80%替换为[MASK]、10%替换为随机token、10%保持不变
- 核心优势:双向上下文建模,每个被掩码位置都能看到左右两侧的所有token
- 损失计算:仅在被掩码位置计算交叉熵损失
- 训练效率:BERT-Base在16块TPU上训练约4天(1M步)
三、预训练任务二:下一句预测(NSP)
What — NSP是如何工作的?
NSP(Next Sentence Prediction,下一句预测)是BERT的第二个预训练目标。核心思想是:让模型判断两个句子是否为连续的上下文句子。这是一个二分类任务:IsNext(是连续句子)或NotNext(不是连续句子)。
NSP的设计初衷是帮助模型理解句子间关系,这对问答(QA)和自然语言推理(NLI)等任务至关重要。
Why — 为什么需要NSP来补充MLM?
问题一:MLM只建模词级语义,无法建模句子间关系。
MLM通过预测被遮盖的单词学习词级语义表示,但两个句子之间的逻辑关系(因果、递进、转折、并列等)无法通过词级建模习得。例如在问答任务中,模型需要判断第二句是否为第一句的正确答案;在自然语言推理中,模型需要判断两个句子是蕴含、矛盾还是中立。这些都需要句子级别的推理能力。
问题二:NSP让模型学习句子间的语义关系。
通过在预训练阶段引入NSP任务,BERT被迫学习句子级别的表示:判断两个句子是否连续。这使得BERT的表示同时包含词级语义和句子级关系,对问答、自然语言推理等任务有直接帮助。
没有NSP会发生什么?
- 模型缺乏句子级别的语义理解能力,问答和自然语言推理任务性能下降
- Segment Embedding(用于区分句子A和B)无法得到充分训练
- 后续研究发现(RoBERTa):去掉NSP反而效果更好,说明NSP的信号不够强,有时甚至引入噪声
NSP的数据构造方式、训练过程、以及Segment Embedding的作用:
# ============ NSP 样本构造详解 ============
# NSP是一个二分类任务,数据构造方式如下:
# 正样本(IsNext,50%概率):
# 从训练语料中选取连续的两个句子 [A] [B]
# 标记为 IsNext = True
# 负样本(NotNext,50%概率):
# 随机从不同文档中选取一个句子作为B
# 标记为 IsNext = False
# 示例
positive_example = {
"sentence_a": "What did Albert Einstein work on?",
"sentence_b": "He developed the theory of relativity.",
"label": "IsNext", # 正样本:两句话是连续的
}
negative_example = {
"sentence_a": "What did Albert Einstein work on?",
"sentence_b": "The capital of France is Paris.", # 随机选取,无关句子
"label": "NotNext", # 负样本:两句话不连续
}
# ============ BERT的输入格式 ============
# BERT的输入序列格式:[CLS] sentence A [SEP] sentence B [SEP]
# 每个token有三个Embedding之和:Token Embedding + Segment Embedding + Position Embedding
# Segment Embedding(句子段嵌入):
# 所有属于 sentence A 的token → segment_id = 0
# 所有属于 sentence B 的token → segment_id = 1
# [CLS] token 属于 sentence A(segment_id = 0)
# 第一个[SEP] 属于 sentence A(segment_id = 0)
# 第二个[SEP] 属于 sentence B(segment_id = 1)
# 注:segment_id与token是否属于某句子有关,与token本身类型无关
input_with_segments = "[CLS] What did Albert [SEP] He developed [SEP]"
segment_ids = [ 0, 0, 0, 0, 0, 1, 1, 1, 1]
# ^^^^^^^^ ^^^^^^^^^^^^^^^^
# segment 0 (A) segment 1 (B)
# ============ NSP分类器:使用[CLS]位置的输出 ============
# [CLS] token 经过Transformer Encoder后的输出向量:C ∈ R^768
# 接一个线性分类层:IsNext/NotNext = W * C + b
# 损失函数:二分类交叉熵损失
# NSP分类器的PyTorch示意代码
import torch
import torch.nn as nn
class NSPClassifier(nn.Module):
def __init__(self, hidden_size=768):
super().__init__()
self.cls_projection = nn.Linear(hidden_size, 2) # 二分类:IsNext / NotNext
def forward(self, pooled_output):
# pooled_output: [CLS] token的隐藏向量,shape (batch, hidden_size)
return self.cls_projection(pooled_output) # shape (batch, 2)
# 最终预训练总损失 = L_MLM + L_NSP(两者相加,等权重)
# L_total = L_MLM + L_NSP
# NSP的准确率通常较高(~98-99%),因为正负样本区分度大
# 这也说明NSP任务相对简单,提供的信息有限
# 这正是RoBERTa发现可以去掉NSP的原因之一
第三节小结
- 任务定义:二分类(IsNext/NotNext),判断句子B是否为句子A的下一句
- 正样本:50%,来自同一文档的连续句子
- 负样本:50%,随机从不同文档选取的句子
- 分类依据:[CLS] token的输出向量,接线性分类头
- 信号强度:NSP任务过于简单(准确率98-99%),提供的学习信号有限
四、BERT的输入表示:Token + Segment + Position Embedding
What — BERT的输入由哪几部分组成?
BERT的输入表示是三种Embedding的向量之和:Token Embedding(词嵌入)、Segment Embedding(句子段嵌入)、Position Embedding(位置嵌入)。每个token最终被表示为一个768维向量(BERT-Base)。
输入的特殊token包括:[CLS](分类任务输出)、[SEP](句子分隔)、[PAD](填充)。
Why — 为什么BERT需要三种Embedding?
问题一:Token Embedding提供词义信息。
每个token通过查表获得一个初始的语义表示。BERT使用WordPiece分词(词表大小30,522),将不在词表中的词拆分为subword序列。这解决了OOV(未登录词)问题——任何英文词都可以被分解为已知subword的组合。
问题二:Segment Embedding区分句子A和B。
BERT的输入可能包含两个句子(A和B),Segment Embedding用于告诉模型每个token属于哪个句子。这对于NSP任务至关重要——模型需要知道"哪部分是第一句,哪部分是第二句"。
问题三:Position Embedding编码位置信息。
Transformer Encoder本身没有循环结构,无法感知token的绝对位置。Position Embedding通过可学习的向量(Learned Position Embedding)为每个位置分配一个独特的位置表示,使模型能够利用序列中的位置信息。
没有Segment Embedding会发生什么?
- 模型无法区分句子A和B,NSP任务完全失效
- 问答等双句输入任务的性能大幅下降
- 跨句子的注意力机制失去方向性指导
BERT输入表示从原始文本到向量融合的完整流程:
# ============ BERT 输入表示的完整构建流程 ============
# 输入文本:sentence_a = "What did Albert Einstein work on?"
# sentence_b = "He developed the theory of relativity."
# ============ Step 1:WordPiece分词 ============
# BERT使用WordPiece分词,词表大小30522
# 不在词表中的词会被拆分为subword
# subword以"##"前缀标识(如"##ing", "##ed")
tokenizer_example = {
# 原始词 -> WordPiece token序列
"What": ["What"],
"did": ["did"],
"Albert": ["Albert"],
"Einstein": ["Ein", "##stein"], # 被拆分为两个subword
"work": ["work"],
"on": ["on"],
"He": ["He"],
"developed": ["de", "##vel", "##oped"], # 被拆分为三个subword
"the": ["the"],
"theory": ["theory"],
"of": ["of"],
"relativity":["re", "##la", "##ti", "##vi", "##ty"],
}
# 最终token序列(加入特殊token并转为token_id后):
# [CLS=101] [What=2054] [did=2106] [Albert=5851] [Ein=19441] [##stein=6769]
# [work=2566] [on=2066] [SEP=102]
# [He=2003] [de=4653] [##vel=2543] [##oped=2895] [theory=13054] [of=1996]
# [re=2089] [##la=???] [##ti=???] [##vi=???] [##ty=???] [SEP=102]
# 注:以上token_id为示意性质(无法确认真实ID序列,实践中应调用bertTokenizer获取)
# ============ Step 2:查Token Embedding表 ============
# token_ids: [101, 2054, 2106, 5851, 19441, 6769, 2566, 2066, 102, 2003, 4653, 13054, 1996, 2543, 2089, 2895, 102]
# 每个token_id查表得到768维向量
# Token_Embedding: (seq_len, 768)
# ============ Step 3:添加Segment Embedding ============
# sentence_a的token(不含[CLS]和[SEP])→ segment_id=0
# sentence_b的token(不含[CLS]和[SEP])→ segment_id=1
segment_ids = [0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1]
# [CLS]属于sentence_a(segment_id=0),第一个[SEP]属于sentence_a,第二个[SEP]属于sentence_b
# ============ Step 4:添加Position Embedding ============
# 每个位置从可学习的Position Embedding表中查表
position_ids = list(range(17)) # 位置0到16(假设序列长度17)
# ============ Step 5:三种Embedding相加 ============
# input_embedding = Token_Embedding + Segment_Embedding + Position_Embedding
# 最终维度:(batch_size, seq_len, hidden_size) = (1, 17, 768)
# ============ Step 6:通过LayerNorm和Dropout ============
# input_embedding = LayerNorm(input_embedding)
# input_embedding = Dropout(input_embedding, p=0.1)
# ============ BERT输入的序列长度限制 ============
# max_position_embeddings = 512(BERT-Base和Large均如此)
# 超过512的序列需要截断或使用Longformer等长上下文模型
# 特殊token的token_id(固定值,词表中索引):
special_tokens = {
"[PAD]": 0, # padding填充
"[CLS]": 101, # 分类token,位于序列开头
"[SEP]": 102, # 句子分隔token
"[UNK]": 100, # 未知词(OOV的subword)
"[MASK]": 103, # MLM掩码token
}
print(f"[CLS] id = {special_tokens['[CLS]']}, [SEP] id = {special_tokens['[SEP]']}")
第四节小结
- 三种Embedding:Token Embedding(词义)+ Segment Embedding(句子归属)+ Position Embedding(位置)
- 分词方式:WordPiece(30,522词表),OOV词拆分为subword
- 特殊token:[CLS](分类)、[SEP](分隔句子)、[PAD](填充)、[UNK](OOV)、[MASK](掩码)
- 向量维度:BERT-Base为768维,序列长度最大512
五、BERT微调(Fine-tuning):下游任务适配
What — 如何将预训练的BERT适配到下游任务?
BERT的微调(Fine-tuning)阶段非常简洁:将预训练好的BERT权重作为初始化的backbone,在下游任务的标注数据上联合训练所有参数(包括BERT本身和任务特定的头部)。与预训练阶段不同,微调阶段不需要额外的预训练任务,直接用监督信号更新所有参数。
Why — 为什么微调比从头训练效果好得多?
问题一:预训练学到了通用的语言表示。
BERT在33亿token上通过MLM和NSP学到了通用的语法结构和语义表示。这些表示包含了丰富的语言知识:句法依存、语义角色、实体类型等。当在下游任务上微调时,模型只需要在这些通用表示的基础上学习任务特定的知识,而非从零开始。
问题二:微调只需要少量标注数据。
预训练阶段不需要下游任务的标注数据(无监督);微调阶段只需要下游任务的少量标注数据。例如,在CoNLL-2003命名实体识别任务上,仅需约1.4万条标注句子即可达到SOTA水平。
没有预训练-微调范式会发生什么?
- 每个NLP任务都需要大量标注数据从头训练,数据成本极高
- 模型无法利用大规模无监督语料的能力,泛化性能差
- 无法形成NLP领域的"基础模型"生态
BERT微调到四类经典下游任务的输入格式和输出结构:
# ============ 微调策略:所有任务共用BERT backbone + 任务头部 ============
# 微调时:预训练BERT参数 + 任务头部参数 全部联合训练
# 学习率通常比预训练小一个数量级(2e-5 ~ 5e-5)
import torch
import torch.nn as nn
from transformers import BertModel, BertTokenizer
# 加载预训练的BERT-Base
bert_model = BertModel.from_pretrained("bert-base-uncased") # 返回768维输出
tokenizer = BertTokenizer.from_pretrained("bert-base-uncased")
# ============ 任务1:单句文本分类(Sentiment Analysis)============
# 输入格式:[CLS] sentence [SEP]
# 输出:[CLS]位置向量 → 线性分类层 → 类别概率
class SentenceClassifier(nn.Module):
def __init__(self, hidden_size=768, num_labels=2):
super().__init__()
self.bert = BertModel.from_pretrained("bert-base-uncased")
self.classifier = nn.Linear(hidden_size, num_labels) # 二分类
def forward(self, input_ids, attention_mask):
outputs = self.bert(input_ids=input_ids, attention_mask=attention_mask)
pooled_output = outputs.pooler_output # [CLS] token的向量, shape (batch, 768)
logits = self.classifier(pooled_output) # shape (batch, num_labels)
return logits
# 示例:情感分类(正面/负面)
text = "This movie is fantastic!"
inputs = tokenizer(text, return_tensors="pt", padding=True, truncation=True, max_length=128)
# ============ 任务2:句子对分类(Natural Language Inference, NLI)============
# 输入格式:[CLS] sentence_a [SEP] sentence_b [SEP]
# 输出:[CLS]位置向量 → 线性分类层 → 类别(entailment/contradiction/neutral)
class NLIClassifier(nn.Module):
def __init__(self, hidden_size=768, num_labels=3):
super().__init__()
self.bert = BertModel.from_pretrained("bert-base-uncased")
self.classifier = nn.Linear(hidden_size, num_labels) # 三分类
def forward(self, input_ids_a, input_ids_b, attention_mask):
# BERT原生支持句子对输入(自动拼接加[SEP])
outputs = self.bert(input_ids=input_ids_a, attention_mask=attention_mask)
pooled_output = outputs.pooler_output # [CLS]向量
logits = self.classifier(pooled_output)
return logits
# ============ 任务3:问答任务(Question Answering,SQuAD)============
# 输入格式:[CLS] question [SEP] paragraph [SEP]
# 输出:两个线性层分别预测答案起始位置和结束位置(span extraction)
class QAModel(nn.Module):
def __init__(self, hidden_size=768):
super().__init__()
self.bert = BertModel.from_pretrained("bert-base-uncased")
self.qa_outputs = nn.Linear(hidden_size, 2) # 输出起始和结束logits
def forward(self, input_ids, attention_mask, token_type_ids=None):
outputs = self.bert(input_ids=input_ids, attention_mask=attention_mask,
token_type_ids=token_type_ids)
sequence_output = outputs.last_hidden_state # shape (batch, seq_len, 768)
logits = self.qa_outputs(sequence_output) # shape (batch, seq_len, 2)
start_logits, end_logits = logits.split(1, dim=-1) # 各shape (batch, seq_len, 1)
start_logits = start_logits.squeeze(-1) # shape (batch, seq_len)
end_logits = end_logits.squeeze(-1) # shape (batch, seq_len)
return start_logits, end_logits
# ============ 任务4:序列标注(Named Entity Recognition, NER)============
# 输入格式:[CLS] token1 token2 token3 ... [SEP]
# 输出:每个token位置 → 线性分类层 → NER标签(B-PER, I-PER, B-ORG, O等)
class NERModel(nn.Module):
def __init__(self, hidden_size=768, num_labels=9):
super().__init__()
self.bert = BertModel.from_pretrained("bert-base-uncased")
self.dropout = nn.Dropout(0.1)
self.classifier = nn.Linear(hidden_size, num_labels) # 每个token输出一个标签
def forward(self, input_ids, attention_mask):
outputs = self.bert(input_ids=input_ids, attention_mask=attention_mask)
sequence_output = outputs.last_hidden_state # shape (batch, seq_len, 768)
sequence_output = self.dropout(sequence_output)
logits = self.classifier(sequence_output) # shape (batch, seq_len, num_labels)
return logits
# BERT微调的超参数(通常)
fine_tune_config = {
"learning_rate": 2e-5, # 比预训练小(预训练用1e-4)
"batch_size": 16 or 32,
"epochs": 3-5, # 下游任务通常只需要很少的epoch
"warmup_ratio": 0.1, # 学习率warmup
"weight_decay": 0.01, # AdamW权重衰减
}
第五节小结
- 微调方式:预训练BERT参数 + 任务头部参数,联合训练
- 学习率:2e-5 ~ 5e-5(远小于预训练的1e-4)
- 训练数据:下游任务少量标注数据(通常几千到几万条)
- 任务适配:文本分类([CLS]向量)、问答(start/end logits)、序列标注(每个token向量)
- 核心优势:一个预训练模型通过简单微调即可适配所有NLP任务
六、RoBERTa:去掉NSP的优化版BERT
What — RoBERTa对BERT做了哪些优化?
RoBERTa("RoBERTa: A Robustly Optimized BERT Pretraining Approach",Liu et al., 2019)是Facebook AI对BERT的全面优化版本。它在GLUE、SNLI、SQuAD等所有基准上超过了BERT-Large。核心变化包括:去掉NSP任务、增大训练数据到160GB、延长训练时间、增大batch size。
Why — 去掉NSP为什么反而让效果更好?
问题一:NSP任务提供的学习信号太弱。
BERT的NSP准确率在预训练期间很快达到98-99%,说明这个任务太简单,模型无需学到深层语义就能完成。进一步分析发现:NSP的正负样本区分度太大——负样本来自不同文档,往往有不同的主题(Topic),模型仅凭主题差异就能判断"不是连续句子",无需学习句子间的语义逻辑关系。
问题二:NSP将输入限制为句子对。
固定50%的概率生成句子对,限制了每个训练样本中实际token的数量。如果只输入单个句子,模型可以每次看到更长的连续文本上下文,这对于MLM的学习更有帮助。
没有RoBERTa的优化会发生什么?
- BERT的性能没有充分发挥,仍有很大的优化空间
- NSP任务的局限性没有被识别,误导后续研究者继续使用NSP
- 预训练效率低,训练数据利用不充分
RoBERTa的消融实验证明了每一项优化的贡献:
# ============ RoBERTa 关键优化项 ============
# ============ 优化1:去掉NSP,使用动态掩码 ============
# BERT:训练前生成固定掩码(static masking),整个训练过程用同一组掩码
# RoBERTa:每次将数据送入模型时动态生成掩码(dynamic masking)
# 效果:同等数据量下效果提升,去掉NSP后提升更明显
# 实现:预训练时每次构建batch时随机生成掩码位置
# 这样每个训练样本在不同epoch看到不同的掩码模式
# ============ 优化2:大幅增大训练数据 ============
roberta_datasets = {
"BERT原始": "BooksCorpus + Wikipedia = 约16GB",
"RoBERTa-full": "BooksCorpus + Wikipedia + CC-News + OpenWebText + Stories",
# CC-News: Common Crawl新闻数据集(76GB)
# OpenWebText: Reddit高赞链接抓取的网页文本(38GB)
# Stories: 子集故事数据集(31GB)
# 总计:约160GB(是BERT的10倍)
}
print(f"RoBERTa训练数据量:{roberta_datasets['RoBERTa-full']}")
# ============ 优化3:增大batch size + 延长训练 ============
training_comparison = {
"BERT": {"batch_size": "256 seqs", "steps": "1M", "total_tokens": "约33B tokens"},
"RoBERTa": {"batch_size": "8K seqs (32K tokens)", "steps": "500K", "total_tokens": "约2T tokens"},
}
# RoBERTa训练总token数:500K steps * 8K seqs * 512 tokens/step ≈ 2T tokens
# 训练步数减少但总token数增加,效果更好
# ============ 优化4:更长的序列 ============
# BERT最大序列长度:512 tokens
# RoBERTa:同样512 tokens(但用了更多动态掩码来补偿)
# ============ RoBERTa vs BERT-Large 在各任务上的对比 ============
results_comparison = {
"MNLI (acc)": {"BERT-Large": "86.6%", "RoBERTa-Large": "90.2%"},
"SST-2 (acc)": {"BERT-Large": "94.9%", "RoBERTa-Large": "96.4%"},
"SQuAD 1.1 (EM)": {"BERT-Large": "87.8%", "RoBERTa-Large": "89.4%"},
"SQuAD 2.0 (EM)": {"BERT-Large": "81.8%", "RoBERTa-Large": "89.2%"},
}
# RoBERTa在所有基准上均超越BERT-Large,证明了优化策略的有效性
# ============ RoBERTa的核心结论 ============
roberta_insights = [
"1. NSP任务对预训练无正面贡献,应去除",
"2. 训练数据规模和多样性比单一数据源更重要",
"3. 更大的batch和更长的训练可以改善收敛",
"4. 动态掩码比静态掩码提供更多学习信号",
"5. 这些优化是独立的,每一项都有正向贡献(消融实验验证)",
]
for insight in roberta_insights:
print(insight)
第六节小结
- 去掉NSP:NSP信号太弱(98-99%准确率),去掉后MLM学得更充分
- 动态掩码:每次送入模型时动态生成掩码,避免重复使用同一掩码模式
- 训练数据:从16GB扩展到160GB(10倍),数据多样性和规模并重
- 大batch训练:batch size从256提升到8K,训练步数从1M降到500K
- 效果:RoBERTa-Large在所有GLUE基准上超过BERT-Large
七、ALBERT:轻量化BERT的技术路线
What — ALBERT如何实现BERT的轻量化?
ALBERT("ALBERT: A Lite BERT for Self-supervised Learning of Language Representations",Lan et al., 2019)提出了两个核心参数削减技术:跨层参数共享(Cross-layer Parameter Sharing)和句子顺序预测(Sentence Order Prediction, SOP)替代NSP。ALBERT-Base仅用12M参数(是BERT-Base的1/9),却能达到BERT-Base约95%的下游任务性能。
Why — 为什么BERT的参数效率很低?
问题一:每层Transformer参数独立,层数增加时参数线性增长。
BERT-Large有24层Transformer,每层都有一组独立的Q/K/V投影矩阵、FFN权重、LayerNorm参数。24层意味着这些参数被复制了24次。但实际上,浅层和深层的表示有很高的相似性(尤其是底层的语法信息),大量参数是冗余的。
问题二:SOP比NSP提供更强的句子级预训练信号。
如RoBERTa研究所示,NSP区分正负样本过于容易(不同文档的主题差异太大)。SOP(Sentence Order Prediction,句子顺序预测)作为句子级预训练主目标:负样本不再是来自不同文档的随机句子,而是来自同一文档但顺序颠倒的两个句子(如[SEP] B [SEP] A)。这样的负样本要求模型真正理解句子间的逻辑顺序,而非仅凭主题差异判断。
没有参数共享会发生什么?
- 大模型的参数存储和计算成本极高,部署困难
- 无法将BERT扩展到更大规模(层数越多,参数越多)
- 小设备(手机/嵌入式)无法运行BERT
ALBERT的三种参数共享策略,以及SOP与NSP的对比:
# ============ ALBERT 参数共享的三种策略 ============
# 策略1:只共享注意力参数(Attention)
# 12层共享同一组 W_Q, W_K, W_V,但每层有自己的 FFN 权重
# 参数削减:约减少40-50%
# 策略2:只共享FFN参数
# 12层共享同一组 FFN 权重,但每层有自己的 Q/K/V
# 参数削减:约减少60-70%
# 策略3:全部共享(Full Sharing,所有参数)
# 12层完全共享同一组参数(ALBERT默认方式)
# 参数削减:约减少80-90%
# 模型效果:与BERT-Base相比,下游任务性能保持约95%
# ============ 参数计算对比 ============
param_comparison = {
"BERT-Base": {
"params": "110M",
"layers": 12,
"hidden": 768,
"sharing": "无(每层独立参数)",
},
"ALBERT-Base": {
"params": "12M",
"layers": 12,
"hidden": 768,
"sharing": "全参数共享(12层共用一组)",
},
"BERT-Large": {
"params": "340M",
"layers": 24,
"hidden": 1024,
"sharing": "无",
},
"ALBERT-Large": {
"params": "18M",
"layers": 24,
"hidden": 1024,
"sharing": "全参数共享",
},
}
# ALBERT的参数削减比例:BERT-Base的110M -> ALBERT-Base的12M(约1/9)
# ALBERT-Large的18M vs BERT-Large的340M(约1/19)
# ============ SOP vs NSP:更强的句子级预训练 ============
# NSP(BERT使用):
# 正样本:同文档连续句子 [A] [SEP] [B]
# 负样本:不同文档随机句子 [A] [SEP] [B']
# 问题:不同文档的句子主题差异大,模型只需比较主题就能区分
# SOP(ALBERT使用):
# 正样本:同文档连续句子 [A] [SEP] [B](与NSP正样本相同)
# 负样本:同文档但顺序颠倒 [B] [SEP] [A](不是随机的!)
# 挑战:两个句子主题相同,必须理解句子间的顺序逻辑
# SOP要求模型理解:
# "The man walked to the store. He bought some milk."
# vs.
# "He bought some milk. The man walked to the store."(负样本)
# 负样本的顺序逻辑是错误的(代词"He"指向错误),模型必须学到语序关系
# ============ ALBERT的嵌入因子分解 ============
# BERT:Token Embedding与隐藏层维度相同(768维)
# ALBERT:将嵌入层分解为更小的因子
# Embedding_E: 先将词映射到E维(E << 768),再投影到768维
# 这进一步减少了Token Embedding层的参数量
# E=128时:词表30K * 128 = 3.8M(vs BERT的 30K * 768 = 23M)
# ALBERT-Base配置
albert_config = {
"embedding_size": 128, # 词嵌入维度(远小于hidden_size=768)
"hidden_size": 768, # 隐藏层维度
"num_hidden_layers": 12,
"num_attention_heads": 12,
"intermediate_size": 3072,
}
# 总参数量约 12M(vs BERT-Base的 110M)
第七节小结
- 跨层参数共享:所有Transformer层共享一组参数,参数量削减到原来的约1/9(12M vs 110M)
- 嵌入分解:将Token Embedding维度(E)与隐藏层维度(H)解耦,E << H,进一步减少参数量
- SOP替代NSP:负样本来自同文档顺序颠倒的句子,需要理解句子间的逻辑顺序
- 性能:ALBERT-Base在下游任务达到BERT-Base约95%的性能,但推理速度更快
- 局限:参数量减少但计算量不变(仍需12层Transformer计算),推理延迟没有显著改善
关于BERT系列的20个高频问题
Q1. BERT和GPT的核心架构区别是什么?
BERT使用Transformer Encoder(双向注意力),GPT使用Transformer Decoder(单向因果注意力)。Encoder的每个位置可以同时看到左右两侧的所有token,适合理解任务;Decoder每个位置只能看到左侧的token,适合生成任务。这是两条最根本的架构路线分歧。
Q2. 什么是WordPiece分词,BERT为什么用它?
WordPiece是一种基于子词的分词算法,将词进一步拆分为subword单元(如"Einstein"拆为"Ein"+"##stein")。BERT使用WordPiece(词表30,522)来解决OOV(未登录词)问题:任何英文词都可以分解为已知subword的组合。同时,subword粒度也使MLM的掩码更有意义——被掩码的是subword而非整个词,预测难度更合理。
Q3. 为什么MLM的掩码率是15%,而不是更高或更低?
15%是在精度和效率之间的经验性平衡。如果掩码率太低(如5%),每个训练step中只有极少数token贡献损失,训练效率低、收敛慢。如果掩码率太高(如50%),每个序列中大部分token被遮盖,破坏上下文完整性,预测难度过大且学到的双向上下文不完整。
Q4. 80/10/10掩码策略中的10%随机替换有什么作用?
随机替换(10%)迫使模型在预测时不只依赖双向上下文,还要能处理被错误替换的噪声token。如果100%用[MASK],微调阶段没有[MASK] token会导致预训练-微调不匹配(pre-train vs fine-tune mismatch)。加入10%随机替换增加了任务难度,提高了模型的鲁棒性。
Q5. NSP任务为什么在RoBERTa中被证明是无效的?
因为NSP的负样本(来自不同文档的随机句子)与正样本(同一文档的连续句子)在主题上天然不同,模型只需比较主题就能判断,无需学习句子间的语义逻辑关系。这使得NSP在预训练期间准确率很快达到98-99%,说明任务过于简单,无法提供足够的学习信号。
Q6. BERT微调时为什么用较小的学习率(如2e-5)?
因为预训练的BERT参数已经包含丰富的语言知识,过大的学习率会破坏这些已学到的表示。预训练用1e-4是因为从头训练需要较大学习率;微调用2e-5~5e-5是为了在保留预训练知识的同时学习下游任务的新知识——相当于在"微调"而非"重学"。
Q7. Segment Embedding在BERT中是如何工作的?
Segment Embedding为句子A中的所有token分配同一个可学习向量(segment_id=0),为句子B中的所有token分配另一个向量(segment_id=1)。[CLS] token属于句子A,第一个[SEP]属于句子A,第二个[SEP]属于句子B。这个信息通过与Token Embedding和Position Embedding相加,融合进每个token的最终表示中。
Q8. BERT的[CLS] token为什么可以作为分类表示?
因为Transformer的双向注意力机制使得[CLS]位置可以 attend to 序列中所有其他token,从而汇聚整个输入序列的信息。这与单向注意力的GPT不同(GPT的[CLS]只能看到左侧)。[CLS]的输出向量经过一个线性层即可用于分类任务,而不需要对所有token做平均或池化。
Q9. RoBERTa的"动态掩码"是什么意思?
动态掩码是指每次将训练样本送入模型时,动态生成新的掩码位置,而不是在训练前预先生成一组固定的掩码。BERT在数据预处理时就生成了固定的掩码位置,每个样本在所有训练epoch中使用相同的掩码。RoBERTa在每次构建训练batch时动态生成掩码,使得模型在不同step中看到同一文本的不同掩码版本,提高了数据利用率。
Q10. ALBERT的参数共享具体共享了哪些参数?
ALBERT默认共享所有Transformer层的参数,包括:Q/K/V投影矩阵、FFN权重、LayerNorm参数、注意力偏置。12层Transformer完全使用同一组参数(不是复制12份),参数量削减到原来的约1/9(ALBERT-Base 12M vs BERT-Base 110M)。
Q11. 为什么ALBERT减少了参数但推理速度并没有等比例提升?
因为ALBERT减少的是参数存储量,但Transformer的计算量(FLOPs)取决于层数和隐藏维度,而非参数是否共享。ALBERT仍然需要运行12层Transformer的计算,每层的计算量与BERT相同。参数共享只减少了内存占用(模型加载),不减少计算延迟。因此ALBERT在内存受限场景(边缘设备)有优势,但推理延迟与BERT相近。
Q12. SOP(句子顺序预测)和NSP在数据构造上有什么区别?
NSP的负样本来自不同文档的随机句子;SOP的负样本来自同一文档但顺序颠倒的两个句子。这一差异至关重要:SOP的负样本与正样本主题相同,模型必须理解句子间的逻辑顺序才能区分,无法靠"主题差异"作弊。这使得SOP是一个更有挑战性的句子级预训练任务。
Q13. BERT预训练的损失函数是什么?
BERT预训练的总损失是MLM损失和NSP损失的加权和:L_total = L_MLM + L_NSP。MLM损失是所有被掩码位置上的交叉熵损失之和;NSP损失是句子对二分类的交叉熵损失。两者的权重相等(各1.0),最终总损失为两者的算术平均。
Q14. 为什么BERT的Position Embedding最大只能支持512个token?
因为BERT在预训练时将Position Embedding表的大小固定为512(max_position_embeddings = 512)。超过512的序列无法从表中查到对应的Position Embedding。如果需要处理更长文本,需要使用Longformer、BigBird等支持长上下文的模型,或者对超长文本进行分段处理后分别编码。
Q15. BERT和BERT-WWM(Whole Word Masking)的区别是什么?
BERT-WWM在掩码时以整个词为单位进行遮盖,而不是遮盖单个WordPiece subword。例如"BERT"可能被分为["B", "##ER", "##T"]三个subword,普通BERT可能只掩码其中一个subword;WWM则会掩码所有subword(整个词)。WWM要求模型预测完整的词而非subword,在中文BERT上效果尤其显著(中文以词为单位掩码)。
Q16. 为什么RoBERTa的训练数据量是BERT的10倍(160GB vs 16GB)?
因为RoBERTa使用了更多样化的数据源:BooksCorpus + Wikipedia + CC-News(新闻数据,76GB)+ OpenWebText(Reddit高赞链接,38GB)+ Stories(故事子集,31GB)。数据多样性的提升(新闻、网页、故事等不同体裁)比单一数据源的规模扩大对模型泛化能力的提升更有效。
Q17. BERT的MLM和GPT的语言建模(LM)有何本质区别?
MLM是"掩码-预测",允许同时看到双向上下文;LM是"自回归-预测",只能看到单向(左侧)上下文。MLM的核心优势是双向上下文建模,适合理解任务;LM的核心优势是天然支持自回归生成。两者的优化目标也不同:MLM只在被掩码位置计算损失,LM在每个位置计算损失。
Q18. BERT在问答(QA)任务上是如何预测答案span的?
BERT在QA任务中输出两个logits向量:起始位置logits和结束位置logits。预测的答案span是使 start_logits[i] + end_logits[j](其中 i <= j)最大的 (i, j) 区间。训练时,起始位置使用真实答案起始位置对应的token作为正例,结束位置同理。推理时遍历所有 (i, j) 组合,取和最大的区间作为预测答案。
Q19. ALBERT的嵌入分解(Embedding Factorization)是如何减少参数的?
ALBERT将词嵌入层 E(维度V x H,其中V=词表大小,H=隐藏维度)分解为 V x E 和 E x H 两个矩阵的乘积。如果E=128,H=768,则参数量从 V*H = 30522*768(约23.4M)减少到 V*E + E*H = 30522*128 + 128*768(约4M)。由于E远小于H,参数量大幅削减。
Q20. BERT系列(BERT/RoBERTa/ALBERT)和GPT系列的本质区别是什么?
BERT系列(Encoder路线)擅长理解任务,适合分类、问答、序列标注;GPT系列(Decoder路线)擅长生成任务,适合文本生成、代码补全。两者代表了两条互补的技术路线:BERT看完全局再输出,GPT逐token生成。近年来,GPT-3证明足够大的Decoder-only模型也能完成理解任务(通过将理解转化为生成),但BERT在同等参数量下理解任务效果仍更优。
全篇总纲
- 架构:Transformer Encoder(双向注意力)vs GPT的Decoder(单向因果注意力)
- MLM:15%掩码率 + 80/10/10策略,实现双向上下文建模
- NSP:IsNext/NotNext二分类,帮助学习句子间关系(但被RoBERTa证明效果有限)
- RoBERTa:去掉NSP + 动态掩码 + 160GB数据 + 大batch,效果超越BERT
- ALBERT:跨层参数共享 + SOP替代NSP + 嵌入分解,参数量削减90%
- 演进规律:BERT -> RoBERTa(数据+训练优化)-> ALBERT(参数效率优化)
下篇预告:大模型时代的中文BERT:RoBERTa-wwm、MacBERT与预训练演进
本篇介绍了英文BERT系列的核心原理。下篇将聚焦中文预训练模型的演进:RoBERTa-wwm-ext(中文Whole Word Masking预训练)、MacBERT(用近义词替代[MASK]减少预训练-微调不匹配)、ERNIE(知识增强)等中文BERT变体,以及它们在中文NLP任务上的具体效果。

浙公网安备 33010602011771号