自然语言处理(五)—— BERT 与预训练模型
Chen Kai BOSS

2018 年, Google 发布的 BERT( Bidirectional Encoder Representations from Transformers)彻底改变了自然语言处理领域。在此之前,预训练模型主要采用单向语言建模(如 GPT),只能利用上下文的一个方向。 BERT 通过双向编码器架构和掩码语言建模( Masked Language Modeling),在 11 项 NLP 任务上刷新了最佳性能,开启了"预训练-微调"范式的黄金时代。

BERT 的成功不仅在于其架构创新,更在于它证明了大规模预训练模型可以成为 NLP 任务的通用基础。从 BERT 开始, RoBERTa 、 ALBERT 、 ELECTRA 等变体不断涌现,每个都在不同维度上优化了 BERT 的设计。理解 BERT 不仅是理解现代 NLP 的关键,更是深入大语言模型时代的起点。

本文将深入剖析 BERT 的架构设计、训练策略和微调方法,通过 HuggingFace 实战代码展示如何在实际任务中使用 BERT,并对比分析各种 BERT 变体的改进思路。

预训练-微调范式的兴起

在 BERT 出现之前, NLP 任务的主流做法是为每个任务单独训练一个模型。这种做法存在明显的局限性:每个任务都需要大量标注数据,而标注成本高昂;不同任务之间无法共享知识,导致资源浪费。

从 Word2Vec 到 ELMo:预训练思想的演进

预训练的思想并非 BERT 首创。早在 2013 年, Word2Vec 就通过无监督学习训练词向量,这些词向量可以作为下游任务的初始化参数。但 Word2Vec 存在一个根本问题:每个词只有一个固定的向量表示,无法处理一词多义

2018 年, ELMo( Embeddings from Language Models)首次提出了上下文相关的词表示。 ELMo 使用双向 LSTM 语言模型,为每个词生成依赖于上下文的向量:

其中 是第 层 LSTM 在位置 的隐状态, 是任务特定的权重, 是缩放因子。

ELMo 的贡献在于证明了上下文相关的预训练表示可以显著提升下游任务性能。但它仍然使用 RNN 架构,无法充分利用并行计算,训练效率较低。

GPT-1:单向预训练的尝试

2018 年 6 月, OpenAI 发布了 GPT-1( Generative Pre-trained Transformer),首次将 Transformer 架构用于预训练。 GPT-1 使用单向语言建模目标:GPT-1 在多个任务上取得了不错的效果,但单向建模限制了模型对上下文的理解能力。例如,在理解句子 "The bank is closed" 时, GPT 只能看到 "The bank is",无法利用 "closed" 的信息来更好地理解 "bank" 的含义。

BERT 的突破:双向预训练

BERT 的核心创新在于双向预训练。与 GPT 的单向建模不同, BERT 可以同时利用上下文的前后信息,这使得它在理解任务上具有天然优势。

BERT 的预训练-微调范式可以概括为:

  1. 预训练阶段:在大规模无标注语料上训练,学习通用的语言表示
  2. 微调阶段:在特定任务的标注数据上微调,适应具体任务需求

这种范式的优势在于: - 数据效率高:预训练模型已经学习了丰富的语言知识,微调时只需要少量标注数据 - 通用性强:同一个预训练模型可以用于多种下游任务 - 性能优异:预训练模型在下游任务上通常能达到更好的性能

BERT 架构详解

BERT 基于 Transformer 的编码器部分构建,但做了一些关键修改以适应双向预训练的需求。

整体架构

BERT 的输入表示由三个部分相加得到:

Token Embedding:将输入 token 映射为向量。 BERT 使用 WordPiece 分词,词汇表大小为 30,000 。

Segment Embedding:用于区分不同的句子。 BERT 可以处理句子对任务(如问答),第一个句子用,第二个句子用

Position Embedding:与 Transformer 相同,使用可学习的位置编码,最大序列长度为 512 。

BERT 在输入序列的开头添加了特殊的 [CLS] token,其最终表示用于分类任务。句子之间用 [SEP] token 分隔。

双向编码器

BERT 的核心是双向 Transformer 编码器。与 GPT 的单向注意力不同, BERT 的每个位置都可以关注到序列中的所有位置(包括前后):

在 BERT 中, 都来自同一个输入序列,这使得每个 token 都可以"看到"整个序列的信息。

BERT 的编码器由 层 Transformer 块堆叠而成,每层包含:

  • 多头自注意力机制( Multi-Head Self-Attention)
  • 前馈神经网络( Feed-Forward Network)
  • 残差连接和层归一化( Residual Connection & Layer Normalization)

BERT 的两种规模

BERT 发布了两种规模的模型:

BERT-Base

  • 层数
  • 隐藏层维度
  • 注意力头数
  • 参数量: 110M

BERT-Large

  • 层数
  • 隐藏层维度
  • 注意力头数
  • 参数量: 340M

BERT 的训练策略

BERT 使用两个无监督预训练任务:掩码语言建模( Masked Language Modeling, MLM)和下一句预测( Next Sentence Prediction, NSP)。

掩码语言建模( MLM)

MLM 是 BERT 的核心预训练任务。具体做法是:

  1. 随机选择输入序列中 15% 的 token 进行掩码
  2. 在这 15% 中:
    • 80% 替换为 [MASK] token
    • 10% 替换为随机 token
    • 10% 保持不变
  3. 模型需要预测被掩码的原始 token

为什么是 80%-10%-10%的分配?

这种策略的设计有深刻的考虑,让我们逐一分析:

80% 使用 [MASK]:让模型学习预测任务

这是 MLM 的主要学习信号。当模型看到[MASK]时,它知道这里有信息缺失,需要根据上下文推断原始 token 。

示例

1
2
3
原始: The cat sat on the mat.
掩码: The [MASK] sat on the mat.
模型任务:预测[MASK]位置是"cat"

这让模型学习到: - 理解上下文关系("sat"通常由动物或人做) - 语法结构(这里需要一个名词) - 语义一致性(能"sit"的东西)

10% 替换为随机 token:防止过度依赖[MASK]

问题:如果全部使用[MASK],模型可能学到一个捷径——"只要看到[MASK]就启动预测模式"。但在下游任务微调时,输入中没有[MASK] token,模型会不适应。

解决方案: 10%的情况下用随机 token 替换:

1
2
3
原始: The cat sat on the mat.
掩码: The apple sat on the mat.
模型任务:预测这个位置的原始 token 是"cat",尽管看到的是"apple"

这强迫模型学习: - 即使输入看起来"正确"("apple"也是名词),也要检查是否符合上下文 - 不能仅依赖特殊标记,要真正理解语义 - 提高对噪声的鲁棒性(现实文本可能包含拼写错误)

数学上的直觉:随机替换引入噪声,让模型学习一个更鲁棒的表示,而不是过拟合于[MASK]这个特殊符号。

10% 保持不变:学习利用上下文信息

问题:如果模型总是看到被修改的 token([MASK]或随机词),它可能学到"输入总是错误的"这个假设。

解决方案: 10%的情况下保持原词不变:

1
2
3
原始: The cat sat on the mat.
掩码: The cat sat on the mat. (看起来没变)
模型任务:仍然需要"预测"这个位置是"cat"

这让模型学习: - 不能假设输入一定是错的:有时候 token 本身就是正确的,模型需要确认这一点 - 深度理解上下文:即使 token 没有显式标记,模型也要检查它是否与上下文一致 - 判别能力:学会区分"这个词是正确的"和"这个词需要替换"

实际训练效果

假设有 1000 个 token, 15%即 150 个被选中: - 120 个( 80%)→ [MASK]:主要学习信号,模型学习预测 - 15 个( 10%)→ 随机词:防止过拟合[MASK],提高鲁棒性 - 15 个( 10%)→ 保持原样:学习确认正确的 token

这种混合策略让 BERT 学到的表示既能处理掩码预测任务,又能适应没有掩码的真实文本。

MLM 的损失函数

MLM 的损失函数只对被掩码的位置计算:

其中: - 是被掩码的位置集合( 15%的 token) - 表示除了被掩码位置外的所有 token - 是模型预测位置 的原始 token 的概率

为什么只对掩码位置计算损失?

因为非掩码位置的 token 没有被修改,模型不需要预测它们。这种设计让训练聚焦于有意义的预测任务,避免浪费计算资源。

数学细节

对于被掩码的位置,模型输出一个词汇表大小的概率分布:

其中 是词 的分数(未归一化的 logits), 是词汇表。

损失函数使用交叉熵,鼓励模型给正确的原始 token 分配高概率。

MLM 的设计权衡

优点: - ✅ 双向上下文:可以同时利用左右两侧的信息 - ✅ 深度理解:强迫模型真正理解语义,而不是简单的模式匹配 - ✅ 鲁棒性:通过随机替换和保持不变,提高对噪声的容忍度

缺点: - ⚠️ 预训练-微调差异:预训练时有[MASK],微调时没有,存在分布不匹配 - ⚠️ 训练效率:每个 batch 只有 15%的 token 参与损失计算,数据利用率相对较低

为什么这些缺点是可接受的?

  1. 分布不匹配:虽然存在,但 10%的随机替换和 10%的保持不变已经部分缓解了这个问题。实验表明,这种不匹配的影响很小。

  2. 训练效率:虽然每个 batch 只有 15%的 token 贡献损失,但 BERT 通过大规模数据和长时间训练弥补了这一点。相比于完全自回归模型(如 GPT), BERT 的双向理解能力在理解任务上更有优势。

下一句预测( NSP)

NSP 任务用于学习句子间的关系,这对问答、自然语言推理等任务很重要。

训练数据的构造方式

训练数据以句子对的形式呈现: - 50% 的样本:句子 A 和句子 B 是连续的( IsNext) - 50% 的样本:句子 B 是随机选择的( NotNext)

示例

IsNext(正样本)

1
2
3
句子 A: The cat sat on the mat.
句子 B: It was very comfortable.
标签: IsNext( B 是 A 的下一句)

NotNext(负样本)

1
2
3
句子 A: The cat sat on the mat.
句子 B: Quantum mechanics is fascinating.
标签: NotNext( B 是随机选择的)

模型如何判断

模型使用 [CLS] token 的表示进行二分类:

其中: - CLS[CLS] token 经过 BERT 编码后的表示向量 - 是分类权重矩阵 - 是 sigmoid 函数 - 输出范围,接近 1 表示 IsNext,接近 0 表示 NotNext

NSP 的损失函数

其中

使用交叉熵损失,鼓励模型正确分类句子对的关系。

NSP 任务的设计动机

BERT 的设计者认为,理解句子间的关系对于许多 NLP 任务至关重要:

  • 问答( QA):问题和候选答案之间的关系
  • 自然语言推理( NLI):前提和假设之间的蕴含关系
  • 文档理解:段落之间的逻辑连贯性

通过 NSP 任务, BERT 可以学习: - 句子间的语义连贯性 - 主题的延续性 - 逻辑关系(因果、转折等)

NSP 任务的局限性

尽管 NSP 的设计动机合理,但后续研究(特别是 RoBERTa)发现, NSP 任务存在一些问题:

1. 任务过于简单

NSP 任务可能过于容易,模型主要通过主题预测而非句子间的逻辑关系来完成任务。

示例分析

IsNext

1
2
A: The cat sat on the mat.
B: It was very comfortable.
主题:猫、舒适

NotNext

1
2
A: The cat sat on the mat.
B: Quantum mechanics is fascinating.
主题:猫 vs 量子力学(主题完全不同)

模型可能只是学到了"主题相同 → IsNext,主题不同 → NotNext",而不是真正理解句子间的逻辑连贯性。

2. 负样本质量问题

随机选择的负样本( NotNext)通常与正样本主题差异巨大,太容易区分。模型没有学习到细致的连贯性判断。

理想的负样本应该是主题相关但逻辑不连贯的句子,例如:

1
2
A: The cat sat on the mat.
B: Dogs are loyal animals. (主题相关但不连贯)

而不是:

1
2
A: The cat sat on the mat.
B: The stock market crashed. (主题完全无关)

3. 对下游任务的帮助有限

RoBERTa 的实验表明,移除 NSP 任务后,模型在多个下游任务上的性能不降反升。这说明 NSP 可能引入了不必要的训练噪声,或者学习到的表示与下游任务不太匹配。

改进方案: ALBERT 的 SOP

ALBERT 提出了句子顺序预测( Sentence Order Prediction, SOP)任务来替代 NSP:

  • 正样本:句子 A 和 B 按正常顺序
  • 负样本:句子 A 和 B 顺序颠倒

示例

正样本

1
2
A: The cat jumped off the mat.
B: It ran towards the door.

负样本

1
2
A: It ran towards the door.
B: The cat jumped off the mat.

SOP 任务更关注句子间的连贯性和逻辑顺序,而不是主题相似性。实验表明, SOP 在下游任务上的效果优于 NSP 。

NSP 的实践建议

基于后续研究的发现:

  1. 如果使用原始 BERT: NSP 是预训练的一部分,不需要特殊处理
  2. 如果从头训练 BERT:可以考虑移除 NSP 或使用 SOP 替代
  3. 如果微调 BERT: NSP 的影响不大,主要关注 MLM 学到的表示

联合训练

BERT 的最终预训练损失是两个任务的联合:

预训练数据: BERT 使用 BooksCorpus( 8 亿词)和英文维基百科( 25 亿词)进行训练,总共约 33 亿词。

BERT 变体与改进

BERT 的成功激发了大量后续研究,各种变体在不同维度上优化了 BERT 的设计。

RoBERTa:更鲁棒的 BERT

RoBERTa( Robustly Optimized BERT Pretraining Approach)由 Facebook 在 2019 年提出,主要改进包括:

1. 移除 NSP 任务 - 研究发现 NSP 任务对性能提升有限,甚至可能有害 - RoBERTa 只使用 MLM 任务进行预训练

2. 动态掩码 - BERT 在数据预处理时静态掩码,每个 epoch 使用相同的掩码模式 - RoBERTa 在每个 epoch 动态生成掩码,增加训练数据的多样性

3. 更大的批次和更长的训练 - BERT:批次大小 256,训练 1M 步 - RoBERTa:批次大小 8K,训练更长时间

4. 更大的训练数据 - 除了 BooksCorpus 和维基百科,还使用 CC-News 、 OpenWebText 、 Stories 等

RoBERTa 在多个任务上超越了 BERT,证明了优化训练策略的重要性。

ALBERT:参数共享的轻量级 BERT

ALBERT( A Lite BERT)在 2019 年提出,主要目标是减少参数量同时保持性能。

1. 因子分解嵌入参数化( Factorized Embedding Parameterization) - BERT 中,词嵌入维度 必须等于隐藏层维度 - ALBERT 将嵌入矩阵分解为两个矩阵:,其中 - 参数量从 减少到 2. 跨层参数共享( Cross-Layer Parameter Sharing) - BERT 的每一层都有独立的参数 - ALBERT 让所有层共享参数,大幅减少参数量 - 实验表明,共享注意力参数和 FFN 参数效果最好

3. 句子顺序预测( SOP)替代 NSP - NSP 任务太简单,模型主要学习主题预测而非句子关系 - SOP 预测两个句子的顺序是否颠倒,更关注句子间的连贯性

ALBERT-xxlarge( 12 层)的参数量只有 BERT-large 的 70%,但在多个任务上性能更好。

ELECTRA:高效的预训练方法

ELECTRA( Efficiently Learning an Encoder that Classifies Token Replacements Accursately)在 2020 年提出,用替换 token 检测( Replaced Token Detection, RTD)替代 MLM 。

核心思想: 1. 使用一个小型生成器( Generator)进行 MLM,生成替换 token 2. 使用判别器( Discriminator)判断每个位置的 token 是否被替换 3. 只训练判别器,生成器仅用于数据增强

优势: - 训练效率高: MLM 只预测 15% 的 token, RTD 对所有 token 进行预测,数据利用率更高 - 性能更好: ELECTRA-small 在 GLUE 上的性能接近 BERT-base,但参数量更少

ELECTRA 的损失函数:𝟙𝟙

其中 是生成器替换后的 token, 是判别器。

BERT 变体对比总结

模型 主要改进 参数量 训练效率 性能提升
BERT 基线 110M/340M 基准 基准
RoBERTa 移除 NSP 、动态掩码、更大批次 110M/340M 相似 +2-3%
ALBERT 参数共享、因子分解嵌入 12M-235M 更快 +1-2%
ELECTRA RTD 替代 MLM 14M-335M 更快 +2-3%

下游任务微调

BERT 的强大之处在于其通用性:同一个预训练模型可以通过微调适应多种下游任务。

文本分类

问题背景:文本分类是 NLP 中最常见的任务之一,需要将文本映射到预定义的类别。 BERT 的双向编码能力使其能够同时利用上下文的前后信息,非常适合理解任务。

解决思路: BERT 在序列开头添加特殊的[CLS] token,经过多层 Transformer 编码后,其表示会聚合整个序列的信息。我们在这个表示上添加一个分类头(线性层+softmax),即可完成分类任务。

设计考虑: - [CLS] token 的设计使其专门用于序列级表示 - 分类头通常是一个简单的线性层,参数量很少,易于训练 - 可以使用不同的池化策略(如平均池化),但[CLS]通常效果最好

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
from transformers import BertTokenizer, BertForSequenceClassification
import torch

# 问题:如何将 BERT 用于文本分类任务?
# 解决:使用[CLS] token 的表示作为序列级特征进行分类

# 加载预训练的 tokenizer 和模型
# tokenizer 负责将文本转换为 token IDs,并添加特殊 token([CLS], [SEP])
tokenizer = BertTokenizer.from_pretrained('bert-base-uncased')
# num_labels 指定分类的类别数,模型会自动添加分类头
model = BertForSequenceClassification.from_pretrained('bert-base-uncased', num_labels=2)

# 输入文本
text = "I love this movie!"
# tokenizer 返回字典,包含:
# - input_ids: token IDs, shape: [batch_size, seq_len]
# - attention_mask: 注意力掩码, shape: [batch_size, seq_len]
# - token_type_ids: 句子类型 ID(单句时为全 0), shape: [batch_size, seq_len]
inputs = tokenizer(text, return_tensors='pt', padding=True, truncation=True)

# 前向传播
# model 内部流程:
# 1. Token 嵌入 + 位置嵌入 + 句子嵌入 -> [batch_size, seq_len, hidden_size]
# 2. 12 层 Transformer 编码器 -> [batch_size, seq_len, hidden_size]
# 3. 提取[CLS]位置的表示 -> [batch_size, hidden_size]
# 4. 分类头(线性层)-> [batch_size, num_labels]
outputs = model(**inputs)
logits = outputs.logits # shape: [batch_size, num_labels],未归一化的分数
predictions = torch.argmax(logits, dim=-1) # shape: [batch_size],预测的类别索引

关键点解读: - [CLS] token 的作用:位于序列开头,经过多层编码后聚合了整个序列的信息,适合用于序列级任务 - 分类头的设计:通常只是一个线性层,参数量为hidden_size × num_labels,非常轻量 - 输入格式: tokenizer 会自动添加[CLS][SEP] token,并处理 padding 和 truncation

设计权衡: - ✅ 优点:简单高效,[CLS] token 专门为分类设计 - ⚠️ 注意:对于长文本,可能需要截断,可能丢失信息

常见问题: - Q: 为什么使用[CLS]而不是其他位置? A: [CLS]在预训练时专门学习序列级表示,效果最好 - Q: 如何处理多分类? A: 只需设置num_labels为类别数,使用交叉熵损失即可

使用示例

1
2
3
4
5
6
# 批量处理
texts = ["I love this movie!", "This is terrible."]
inputs = tokenizer(texts, return_tensors='pt', padding=True, truncation=True)
outputs = model(**inputs)
predictions = torch.argmax(outputs.logits, dim=-1)
# predictions: tensor([1, 0]) # 假设 1 是积极, 0 是消极

命名实体识别( NER)

问题背景: NER 是序列标注任务,需要为每个 token 分配一个标签(如人名、地名、组织名等)。与分类任务不同, NER 需要 token 级别的表示,而不是序列级表示。

解决思路: BERT 为每个 token 位置都生成一个表示向量,可以直接在这些表示上添加分类头,为每个 token 预测标签。使用 BIO 标注体系( B-开始, I-内部, O-外部)可以处理多词实体。

设计考虑: - 使用每个 token 的最终层表示,而不是[CLS] - 需要处理 WordPiece 分词导致的子词对齐问题 - 通常使用 CRF 层来保证标签序列的合理性( BIO 约束)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
from transformers import BertTokenizer, BertForTokenClassification
import torch

# 问题:如何将 BERT 用于 token 级别的序列标注任务?
# 解决:使用每个 token 位置的表示进行分类

# 加载模型, num_labels 包括 BIO 标签: O, B-PER, I-PER, B-LOC, I-LOC 等
tokenizer = BertTokenizer.from_pretrained('bert-base-uncased')
model = BertForTokenClassification.from_pretrained('bert-base-uncased', num_labels=9)

# 输入文本
text = "Barack Obama was born in Hawaii"
# tokenizer 会将文本切分为 WordPiece tokens
inputs = tokenizer(text, return_tensors='pt', padding=True, truncation=True)
# 注意: WordPiece 可能导致一个词被切分为多个子词,需要对齐策略

# 前向传播
# model 内部流程:
# 1. Token 嵌入 + 位置嵌入 -> [batch_size, seq_len, hidden_size]
# 2. 12 层 Transformer 编码器 -> [batch_size, seq_len, hidden_size]
# 3. 对每个 token 位置应用分类头 -> [batch_size, seq_len, num_labels]
outputs = model(**inputs)
logits = outputs.logits # shape: [batch_size, seq_len, num_labels]
predictions = torch.argmax(logits, dim=-1) # shape: [batch_size, seq_len]

# 解码:将 token IDs 转换回文本,并与预测标签对齐
tokens = tokenizer.convert_ids_to_tokens(inputs['input_ids'][0])
ner_tags = ['O', 'B-PER', 'I-PER', 'B-LOC', 'I-LOC', 'B-ORG', 'I-ORG', 'B-MISC', 'I-MISC']
for token, pred in zip(tokens, predictions[0]):
# 跳过特殊 token
if token in ['[CLS]', '[SEP]', '[PAD]']:
continue
print(f"{token}: {ner_tags[pred]}")

关键点解读: - Token 级表示: BERT 为每个位置生成表示,包括子词 token,需要处理对齐问题 - BIO 标注体系: B-实体开始, I-实体内部, O-非实体,确保标签序列的合理性 - 子词对齐: WordPiece 分词可能导致一个词被切分,通常将第一个子词的标签分配给整个词

设计权衡: - ✅ 优点:直接利用 BERT 的 token 级表示,无需额外设计 - ⚠️ 注意:需要处理子词对齐,可能需要 CRF 层保证标签一致性

常见问题: - Q: 如何处理 WordPiece 导致的子词问题? A: 通常只对第一个子词预测标签,或使用平均池化 - Q: 为什么需要 CRF 层? A: CRF 可以学习标签之间的转移概率,避免不合理的标签序列(如 I-PER 前面没有 B-PER)

使用示例

1
2
3
4
5
6
7
8
9
# 处理子词对齐的完整示例
def align_predictions(predictions, label_ids, tokenizer):
"""将子词级别的预测对齐到词级别"""
aligned_predictions = []
for pred, label_id in zip(predictions, label_ids):
if label_id == -100: # 忽略的 token(如[CLS], [SEP])
continue
aligned_predictions.append(pred)
return aligned_predictions

问答任务( QA)

问题背景:阅读理解任务需要从给定文档中找到问题的答案。这是一个 span 提取任务,需要预测答案在原文中的起始和结束位置。

解决思路: BERT 使用两个独立的分类头分别预测答案的起始位置和结束位置。对于每个 token 位置,模型输出一个分数,表示该位置是答案开始/结束的概率。最终选择分数最高的起始和结束位置。

设计考虑: - 问题和上下文拼接在一起,用[SEP]分隔 - 使用两个独立的线性层分别预测起始和结束位置 - 需要确保结束位置在起始位置之后 - 可以限制答案的最大长度

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
from transformers import BertTokenizer, BertForQuestionAnswering
import torch

# 问题:如何从文档中提取答案片段?
# 解决:使用两个分类头分别预测答案的起始和结束位置

tokenizer = BertTokenizer.from_pretrained('bert-base-uncased')
model = BertForQuestionAnswering.from_pretrained('bert-base-uncased')

# 问题和上下文
question = "Where was Barack Obama born?"
context = "Barack Obama was born in Hawaii in 1961."

# 编码: tokenizer 会将问题和上下文拼接
# 格式:[CLS] question [SEP] context [SEP]
inputs = tokenizer(question, context, return_tensors='pt', padding=True, truncation=True)
# token_type_ids 会区分问题和上下文: question 部分为 0, context 部分为 1

# 前向传播
# model 内部流程:
# 1. Token 嵌入 + 位置嵌入 + 句子嵌入 -> [batch_size, seq_len, hidden_size]
# 2. 12 层 Transformer 编码器 -> [batch_size, seq_len, hidden_size]
# 3. 起始位置分类头 -> [batch_size, seq_len]
# 4. 结束位置分类头 -> [batch_size, seq_len]
outputs = model(**inputs)
start_scores = outputs.start_logits # shape: [batch_size, seq_len],每个位置作为答案开始的分数
end_scores = outputs.end_logits # shape: [batch_size, seq_len],每个位置作为答案结束的分数

# 找到答案位置
# 简单方法:直接选择分数最高的位置(可能不合理)
start_idx = torch.argmax(start_scores) # shape: [batch_size]
end_idx = torch.argmax(end_scores)

# 更好的方法:确保结束位置在起始位置之后,并选择分数和最高的组合
# start_scores 和 end_scores 都是[batch_size, seq_len]
# 需要计算所有合理的(start, end)组合的分数和
max_score = -float('inf')
best_start, best_end = 0, 0
for start in range(len(start_scores[0])):
for end in range(start, min(start + 30, len(end_scores[0]))): # 限制最大长度
score = start_scores[0][start] + end_scores[0][end]
if score > max_score:
max_score = score
best_start, best_end = start, end

# 解码答案
answer_tokens = inputs['input_ids'][0][best_start:best_end+1]
answer = tokenizer.decode(answer_tokens)
print(f"Answer: {answer}")

关键点解读: - 双分类头设计:起始和结束位置使用独立的分类头,允许模型分别学习这两个任务 - Span 提取:答案必须是原文中的连续片段,不能是多个不连续的部分 - 位置约束:结束位置必须在起始位置之后,且通常限制最大长度

设计权衡: - ✅ 优点:简单直接,不需要生成,直接从原文提取 - ⚠️ 注意:只能提取原文中的片段,无法处理需要推理或生成的答案

常见问题: - Q: 如何处理答案不在文档中的情况? A: 可以设置一个特殊的"无答案"类别,或使用阈值判断 - Q: 为什么需要限制答案长度? A: 避免模型选择过长的片段,提高准确性

使用示例

1
2
3
4
5
6
7
# 处理多个问题的批量推理
questions = ["Where was Obama born?", "When was he born?"]
context = "Barack Obama was born in Hawaii in 1961."
for question in questions:
inputs = tokenizer(question, context, return_tensors='pt', padding=True, truncation=True)
outputs = model(**inputs)
# ... 提取答案 ...

句子对分类

对于自然语言推理等任务,需要处理句子对:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from transformers import BertTokenizer, BertForSequenceClassification

tokenizer = BertTokenizer.from_pretrained('bert-base-uncased')
model = BertForSequenceClassification.from_pretrained('bert-base-uncased', num_labels=3)

# 句子对
premise = "A man is playing guitar"
hypothesis = "Someone is making music"

# 编码(会自动添加 [SEP] token)
inputs = tokenizer(premise, hypothesis, return_tensors='pt', padding=True, truncation=True)

# 前向传播
outputs = model(**inputs)
logits = outputs.logits
predictions = torch.argmax(logits, dim=-1)

微调技巧

问题背景: BERT 微调时,预训练层已经学习了丰富的语言表示,而分类头是随机初始化的。如果使用相同的学习率,可能会导致预训练权重被破坏,或分类头学习过慢。

解决思路:采用分层学习率策略,对预训练层使用较小的学习率(保护已有知识),对分类头使用较大的学习率(快速学习任务特定知识)。同时,对 bias 和 LayerNorm 参数不使用权重衰减。

设计考虑: - 预训练层学习率通常为 1e-5 到 5e-5 - 分类头学习率通常为 1e-4 到 5e-4 - bias 和 LayerNorm 通常不需要权重衰减,因为它们的作用机制不同

1. 学习率设置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
from torch.optim import AdamW

# 问题:如何为不同层设置不同的学习率?
# 解决:将参数分组,为每组设置不同的学习率和权重衰减

# 区分预训练层和分类头
# no_decay 列表中的参数不使用权重衰减( weight_decay=0)
# 原因: bias 和 LayerNorm 的 scale 参数不需要正则化
no_decay = ['bias', 'LayerNorm.weight']

# 参数分组策略:
# 1. 预训练层(非 no_decay):使用较小学习率 + 权重衰减
# 2. 预训练层( no_decay):使用较小学习率 + 无权重衰减
# 3. 分类头:使用较大学习率 + 权重衰减
optimizer_grouped_parameters = [
{
# 预训练层的权重参数(需要权重衰减)
'params': [p for n, p in model.named_parameters()
if not any(nd in n for nd in no_decay) and 'classifier' not in n],
'weight_decay': 0.01, # L2 正则化系数
'lr': 2e-5 # 较小的学习率,保护预训练权重
},
{
# 预训练层的 bias 和 LayerNorm 参数(不需要权重衰减)
'params': [p for n, p in model.named_parameters()
if any(nd in n for nd in no_decay) and 'classifier' not in n],
'weight_decay': 0.0,
'lr': 2e-5
},
{
# 分类头参数(需要较大学习率)
'params': [p for n, p in model.named_parameters() if 'classifier' in n],
'weight_decay': 0.01,
'lr': 1e-4 # 较大的学习率,快速学习任务特定知识
}
]
optimizer = AdamW(optimizer_grouped_parameters)

关键点解读: - 分层学习率:预训练层使用小学习率( 2e-5),分类头使用大学习率( 1e-4),避免破坏预训练知识 - 权重衰减策略: bias 和 LayerNorm 不使用权重衰减,因为它们的作用机制与权重不同 - 参数分组:通过参数名称判断参数类型,实现精细化的优化策略

设计权衡: - ✅ 优点:保护预训练权重,同时快速学习任务特定知识 - ⚠️ 注意:需要仔细设置学习率比例,过大可能导致不稳定

常见问题: - Q: 为什么 bias 不需要权重衰减? A: bias 的更新方向与权重不同,权重衰减可能影响其学习 - Q: 学习率比例如何选择? A: 通常分类头学习率是预训练层的 5-10 倍

2. 梯度累积

问题背景: BERT 模型较大,受 GPU 内存限制,实际批次大小( batch size)可能较小。但较小的批次大小会导致梯度估计不稳定,影响训练效果。

解决思路:梯度累积通过多次前向传播累积梯度,然后一次性更新参数,从而模拟更大的批次大小。这样可以在不增加内存消耗的情况下,获得大批次训练的稳定性。

设计考虑: - 累积步数( accumulation_steps)决定了有效批次大小 = batch_size × accumulation_steps - 需要将损失除以累积步数,确保梯度大小与正常批次一致 - 只在累积完成后更新参数,避免中间更新

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 问题:如何在内存受限的情况下模拟大批次训练?
# 解决:使用梯度累积,多次前向传播累积梯度后再更新

accumulation_steps = 4 # 累积 4 个 batch 的梯度
effective_batch_size = batch_size * accumulation_steps # 有效批次大小

for i, batch in enumerate(dataloader):
# 前向传播
outputs = model(**batch)
# 损失需要除以累积步数,确保梯度大小正确
# 原因: PyTorch 的 backward()会累加梯度,需要平均梯度
loss = outputs.loss / accumulation_steps
loss.backward() # 梯度累积到参数的.grad 属性中

# 每 accumulation_steps 个 batch 更新一次参数
if (i + 1) % accumulation_steps == 0:
optimizer.step() # 更新参数
optimizer.zero_grad() # 清零梯度,准备下一轮累积

关键点解读: - 梯度累积原理:多次backward()会累加梯度,相当于计算了更大批次的平均梯度 - 损失缩放:除以accumulation_steps确保梯度大小与正常批次一致 - 更新时机:只在累积完成后更新,避免中间更新破坏累积效果

设计权衡: - ✅ 优点:在不增加内存的情况下模拟大批次训练,提高训练稳定性 - ⚠️ 注意:会增加训练时间(需要更多前向传播),但内存占用不变

常见问题: - Q: 为什么损失要除以 accumulation_steps? A: 确保梯度大小与正常批次一致,避免梯度过大 - Q: 累积步数如何选择? A: 通常选择 2-8,根据 GPU 内存和训练稳定性调整

3. 学习率调度

问题背景: BERT 微调时,如果直接使用较大的学习率,可能导致训练初期不稳定。同时,随着训练进行,需要逐渐降低学习率以收敛到更好的解。

解决思路:使用 warmup + 线性衰减策略。 Warmup 阶段逐渐增加学习率,让模型平稳进入训练;然后线性衰减学习率,帮助模型精细调优。

设计考虑: - Warmup 步数通常为总步数的 10% - 线性衰减确保学习率平滑下降,避免突然变化 - 可以结合梯度累积计算正确的总步数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
from transformers import get_linear_schedule_with_warmup

# 问题:如何设计学习率调度策略?
# 解决:使用 warmup + 线性衰减,确保训练稳定且收敛良好

# 计算总训练步数(考虑梯度累积)
total_steps = len(dataloader) * num_epochs // accumulation_steps
num_warmup_steps = int(0.1 * total_steps) # Warmup 步数:总步数的 10%

# 学习率调度器
# Warmup 阶段:学习率从 0 线性增加到初始学习率
# 衰减阶段:学习率从初始值线性衰减到 0
scheduler = get_linear_schedule_with_warmup(
optimizer,
num_warmup_steps=num_warmup_steps, # Warmup 步数
num_training_steps=total_steps # 总训练步数
)

# 在训练循环中使用
for epoch in range(num_epochs):
for step, batch in enumerate(dataloader):
# ... 前向传播和反向传播 ...
optimizer.step()
scheduler.step() # 更新学习率
optimizer.zero_grad()

关键点解读: - Warmup 的作用:训练初期梯度可能很大, warmup 让模型平稳进入训练,避免震荡 - 线性衰减:确保学习率平滑下降,帮助模型精细调优,避免过早停止学习 - 步数计算:需要考虑梯度累积,实际更新次数 = 总样本数 / (batch_size × accumulation_steps)

设计权衡: - ✅ 优点:提高训练稳定性,帮助模型收敛到更好的解 - ⚠️ 注意:需要正确计算总步数, warmup 比例需要根据任务调整

常见问题: - Q: Warmup 比例如何选择? A: 通常为总步数的 5-10%,对于小数据集可以更大 - Q: 为什么使用线性衰减而不是其他策略? A: 线性衰减简单有效,对于微调任务通常足够

HuggingFace 实战:完整微调流程

问题背景:实际应用中,需要一个完整的微调流程,包括数据加载、预处理、训练、评估等步骤。 HuggingFace 提供了Trainer类来简化这些流程。

解决思路:使用 HuggingFace 的TrainerTrainingArguments,可以自动处理训练循环、学习率调度、模型保存等细节。我们只需要定义数据预处理函数和训练参数即可。

设计考虑: - 使用DataCollatorWithPadding动态 padding,节省内存 - 设置合适的评估策略,及时监控模型性能 - 使用load_best_model_at_end自动加载最佳模型

下面展示一个完整的文本分类微调流程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
from transformers import (
BertTokenizer,
BertForSequenceClassification,
Trainer,
TrainingArguments,
DataCollatorWithPadding
)
from datasets import load_dataset
import torch
from torch.utils.data import Dataset

# ========== 1. 加载数据和模型 ==========
# 问题:如何加载预训练模型和数据集?
# 解决:使用 HuggingFace 的 from_pretrained 和 load_dataset

# 加载 IMDB 电影评论数据集(情感分析任务)
dataset = load_dataset("imdb") # 包含 train 和 test 两个 split
# 数据集格式:{'text': str, 'label': int}, label: 0=负面, 1=正面

# 加载预训练的 tokenizer 和模型
tokenizer = BertTokenizer.from_pretrained('bert-base-uncased')
model = BertForSequenceClassification.from_pretrained(
'bert-base-uncased',
num_labels=2 # 二分类:正面/负面
)

# ========== 2. 数据预处理 ==========
# 问题:如何将文本转换为模型输入?
# 解决:使用 tokenizer 将文本转换为 token IDs,并处理 padding 和 truncation

def preprocess_function(examples):
"""
预处理函数:将文本转换为 token IDs

Args:
examples: 包含'text'字段的字典,可以是单个样本或批量样本

Returns:
包含'input_ids', 'attention_mask', 'token_type_ids'的字典
"""
return tokenizer(
examples['text'], # 输入文本列表
truncation=True, # 超过 max_length 时截断
padding=True, # 动态 padding(这里先 padding,实际训练时用 DataCollator)
max_length=512 # BERT 最大序列长度
)

# 应用预处理函数到整个数据集
# batched=True 表示批量处理,提高效率
tokenized_dataset = dataset.map(preprocess_function, batched=True)

# ========== 3. 设置训练参数 ==========
# 问题:如何配置训练过程?
# 解决:使用 TrainingArguments 统一管理所有训练超参数

training_args = TrainingArguments(
output_dir='./results', # 模型和日志保存目录
num_train_epochs=3, # 训练轮数
per_device_train_batch_size=16, # 每个设备的训练批次大小
per_device_eval_batch_size=16, # 每个设备的评估批次大小
warmup_steps=500, # Warmup 步数
weight_decay=0.01, # 权重衰减系数
learning_rate=2e-5, # 学习率(预训练层)
logging_dir='./logs', # 日志保存目录
logging_steps=100, # 每 100 步记录一次日志
evaluation_strategy="epoch", # 每个 epoch 评估一次
save_strategy="epoch", # 每个 epoch 保存一次模型
load_best_model_at_end=True, # 训练结束后加载最佳模型
metric_for_best_model="accuracy", # 用于选择最佳模型的指标
save_total_limit=3, # 最多保存 3 个检查点
fp16=True, # 使用混合精度训练(如果 GPU 支持)
)

# ========== 4. 数据整理器 ==========
# 问题:如何动态处理不同长度的序列?
# 解决:使用 DataCollatorWithPadding 在训练时动态 padding

data_collator = DataCollatorWithPadding(tokenizer=tokenizer)
# 作用:将同一 batch 中的序列 padding 到相同长度
# 优势:只在 batch 内 padding,节省内存

# ========== 5. 训练 ==========
# 问题:如何简化训练循环?
# 解决:使用 Trainer 类自动处理训练细节

trainer = Trainer(
model=model, # 要训练的模型
args=training_args, # 训练参数
train_dataset=tokenized_dataset['train'], # 训练集
eval_dataset=tokenized_dataset['test'], # 评估集
data_collator=data_collator, # 数据整理器
# tokenizer=tokenizer, # 可选:用于保存 tokenizer
)

# 开始训练
# Trainer 会自动处理:
# - 前向传播和反向传播
# - 学习率调度
# - 模型保存和加载
# - 日志记录
trainer.train()

# ========== 6. 评估 ==========
# 问题:如何评估模型性能?
# 解决:使用 Trainer 的 evaluate 方法

results = trainer.evaluate()
print(results)
# 输出示例:
# {'eval_loss': 0.25, 'eval_accuracy': 0.92, 'eval_runtime': 12.5, ...}

# 可选:在测试集上预测
predictions = trainer.predict(tokenized_dataset['test'])

关键点解读: - Trainer 的优势:自动处理训练循环、学习率调度、模型保存等细节,减少代码量 - 动态 PaddingDataCollatorWithPadding在训练时动态 padding,只 padding 到 batch 内的最大长度,节省内存 - 评估策略evaluation_strategy="epoch"确保每个 epoch 评估一次,及时监控性能

设计权衡: - ✅ 优点:代码简洁,自动处理很多细节,适合快速原型开发 - ⚠️ 注意:对于复杂需求,可能需要自定义 Trainer 或使用原生 PyTorch 训练循环

常见问题: - Q: 如何自定义评估指标? A: 实现compute_metrics函数并传递给 Trainer - Q: 如何继续训练? A: 使用resume_from_checkpoint参数指定检查点路径

使用示例

1
2
3
4
5
6
7
8
9
10
11
12
# 自定义评估指标
def compute_metrics(eval_pred):
predictions, labels = eval_pred
predictions = np.argmax(predictions, axis=1)
accuracy = accuracy_score(labels, predictions)
f1 = f1_score(labels, predictions)
return {'accuracy': accuracy, 'f1': f1}

trainer = Trainer(
...,
compute_metrics=compute_metrics
)

BERT 的局限性

尽管 BERT 取得了巨大成功,但它也存在一些局限性:

1. 计算成本高 - BERT-base 有 1.1 亿参数, BERT-large 有 3.4 亿参数 - 推理速度较慢,不适合实时应用

2. 单向生成能力弱 - BERT 是双向编码器,不适合生成任务 - 对于文本生成,需要使用 GPT 等自回归模型

3. 最大序列长度限制 - BERT 的最大序列长度为 512,无法处理超长文本 - 虽然可以通过滑动窗口等方法扩展,但效果有限

4. 预训练数据偏向英文 - BERT 主要在英文语料上训练,对其他语言的支持有限 - 多语言 BERT( mBERT)虽然支持多种语言,但性能不如单语言模型

总结

BERT 开启了 NLP 的预训练-微调时代,证明了大规模预训练模型的有效性。从 BERT 开始,各种变体和改进不断涌现,推动了 NLP 领域的快速发展。

BERT 的核心贡献在于: 1. 双向预训练:通过 MLM 任务实现双向上下文理解 2. 通用表示:同一个模型可以适应多种下游任务 3. 预训练-微调范式:大幅降低了 NLP 任务的数据需求

理解 BERT 不仅是理解现代 NLP 的关键,更是深入大语言模型时代的起点。在下一篇文章中,我们将探讨 GPT 系列模型,了解生成式语言模型的魅力。

❓ Q&A: BERT 常见问题

Q1: BERT 为什么使用 [MASK] token 而不是直接预测下一个词?

A: BERT 使用 MLM 任务是为了实现双向预训练。如果像 GPT 那样预测下一个词,模型只能看到左侧上下文,无法利用右侧信息。 MLM 通过掩码部分 token 并预测它们,让模型可以同时利用左右两侧的上下文。

Q2: BERT 的 [CLS] token 为什么可以用于分类?

A: [CLS] token 位于序列开头,经过多层 Transformer 编码后,其表示会聚合整个序列的信息。虽然理论上任何位置的 token 都可以用于分类,但 [CLS] 的设计使其专门用于序列级表示,在预训练和微调过程中学习到了有效的分类特征。

Q3: BERT 和 GPT 的主要区别是什么?

A: 主要区别在于: - 架构: BERT 是双向编码器, GPT 是单向解码器 - 预训练任务: BERT 使用 MLM + NSP, GPT 使用语言建模 - 适用任务: BERT 擅长理解任务(分类、 NER 、 QA), GPT 擅长生成任务 - 上下文利用: BERT 可以同时看到前后文, GPT 只能看到前文

Q4: RoBERTa 为什么移除了 NSP 任务?

A: 研究发现 NSP 任务对性能提升有限,甚至可能有害。 NSP 任务太简单,模型主要学习主题预测而非句子间的逻辑关系。 RoBERTa 通过移除 NSP 并使用更长的序列训练,在多个任务上取得了更好的性能。

Q5: ALBERT 如何减少参数量?

A: ALBERT 主要通过两种方法: 1. 因子分解嵌入:将词嵌入矩阵分解为两个小矩阵,减少嵌入层参数量 2. 跨层参数共享:让所有 Transformer 层共享参数,大幅减少参数量

Q6: ELECTRA 相比 BERT 的优势是什么?

A: ELECTRA 的主要优势是训练效率更高: - MLM 只预测 15% 的 token, RTD 预测所有 token,数据利用率更高 - 相同计算量下, ELECTRA 的性能更好 - ELECTRA-small 的性能接近 BERT-base,但参数量更少

Q7: BERT 可以用于生成任务吗?

A: BERT 本身不适合生成任务,因为它是双向编码器,无法进行自回归生成。但可以通过一些技巧使用 BERT 进行生成: - BERT-GEN:使用 BERT 编码器 + 独立的解码器 - MASS/BART:使用 BERT 风格的编码器-解码器架构

对于纯生成任务, GPT 等自回归模型更合适。

Q8: 如何选择 BERT 的变体?

A: 选择建议: - BERT-base:通用选择,平衡性能和速度 - RoBERTa:追求更高性能,计算资源充足 - ALBERT:参数量受限,需要轻量级模型 - ELECTRA:追求训练效率,或需要小模型但性能接近大模型

Q9: BERT 微调时需要注意什么?

A: 关键注意事项: 1. 学习率:使用较小的学习率( 2e-5),避免破坏预训练权重 2. 批次大小:根据 GPU 内存调整,可以使用梯度累积 3. 训练轮数:通常 2-4 轮即可,避免过拟合 4. warmup:使用学习率 warmup,稳定训练过程 5. 正则化:适当使用 dropout 和权重衰减

Q10: BERT 如何处理多语言任务?

A: BERT 处理多语言任务的方式: 1. mBERT:在多语言语料上预训练,支持 100+ 种语言 2. XLM:使用跨语言预训练目标,增强跨语言迁移能力 3. 单语言 BERT:为每种语言训练专门的模型,性能更好但需要更多资源

对于中文任务,推荐使用 BERT-Chinese 或 RoBERTa-Chinese 。

  • 本文标题:自然语言处理(五)—— BERT 与预训练模型
  • 本文作者:Chen Kai
  • 创建时间:2024-02-26 09:30:00
  • 本文链接:https://www.chenk.top/%E8%87%AA%E7%84%B6%E8%AF%AD%E8%A8%80%E5%A4%84%E7%90%86%EF%BC%88%E4%BA%94%EF%BC%89%E2%80%94%E2%80%94-BERT%E4%B8%8E%E9%A2%84%E8%AE%AD%E7%BB%83%E6%A8%A1%E5%9E%8B/
  • 版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!
 评论