BERT#
学习目标与记号#
准确解释双向编码器、输入表示与掩码语言模型;
根据公式和张量维度分析预训练与微调,并识别常见实现错误;
用
Python实现或验证注意力掩码;在其他条件相同的情况下,只改变一个需要研究的因素进行比较,并根据实验结果分析该因素可能带来的影响。
本节沿用统一记号:普通小写字母表示标量,粗体小写字母表示向量,粗体大写字母表示矩阵或高阶张量;层编号写作上标 \([l]\),样本或时间编号写作下标;转置写作 \(\trans\)。除非另有说明,单个向量均为列向量;若把 \(n\) 个列向量按行组成矩阵,则统一写为 \(\bU=[\bu_1\trans;\ldots;\bu_n\trans]\)。正文与练习中的程序都应同时检查数值结果和数组维度。本节练习的参考答案见 BERT 预训练与微调答案。
BERT 建立在 词元化与嵌入、多头注意力、注意力掩码 和 Transformer 层 之上。
BERT(Bidirectional Encoder Representations from Transformers) 是一类仅使用 Transformer 编码器的预训练语言模型。它先在大规模无标注文本上学习上下文相关的表示,再用带标签的数据适配具体任务。BERT 的关键贡献不是发明了新的注意力运算,而是把“双向编码器预训练—下游任务微调”组织成了一套通用方法。
“双向”指每个词元在编码时都可以关注同一序列中位于它左侧和右侧的词元。例如,仅看到“今天天气真”时,很难判断下一个词是“好”还是“差”;若同时看到后文“适合出去徒步”,判断会容易得多。BERT 的自注意力通常不使用因果掩码,因此适合文本分类、序列标注和抽取式问答等理解任务。它并不是按从左到右的顺序生成文本,因而不应把原始 BERT 直接当作自回归生成模型。
图 31 BERT 使用多层 Transformer 编码器提取双向上下文表示。#
BERT 的结构与输入#
Devlin 等(2019) 的原始论文给出了两个主要配置:BERT-base 包含 12 个编码器层,隐藏维度为 768,使用 12 个注意力头,参数量约为 1.1 亿;BERT-large 包含 24 个编码器层,隐藏维度为 1024,使用 16 个注意力头,参数量约为 3.4 亿。对 BERT-base 而言,每个头的键、查询和值向量维度为 \(768/12=64\)。
文本首先由 WordPiece 分词器拆成词元(token)。高频词通常作为完整词元保留,低频词则可能被拆成若干子词。例如,英文单词 playing 可能被拆成 play 和 ##ing。子词建模能在有限词表下表示大量词形,但词元不等同于自然语言中的“词”,统计序列长度时要特别注意这一点。
位置 \(i\) 的初始表示 \(\bx_i\in\mathbb{R}^{d}\) 是列向量,由三个同维度的列向量相加:
其中,\(\be^{\mathrm{token}}_i,\be^{\mathrm{segment}}_i,\be^{\mathrm{position}}_i\in\mathbb{R}^{d}\) 均为列向量;词元嵌入表示词元本身,片段嵌入区分句子 A 和句子 B,位置嵌入则编码词元在序列中的位置。与上一节介绍的正弦位置编码不同,原始 BERT 的位置嵌入是通过训练学习的。输入一个长度为 \(T\) 的序列时,将这些列向量转置后按行堆叠为 \(\bX=[\bx_1\trans;\ldots;\bx_T\trans]\in\mathbb{R}^{T\times d}\)。
图 32 BERT 输入表示由词元、片段和位置嵌入相加得到。#
BERT 还使用若干特殊词元:
[CLS]放在输入开头,其最后一层表示常被送入任务分类输出层。它并非天然就是“句子语义向量”,其含义取决于预训练目标和下游训练。[SEP]标记句子边界;句子对通常写作[CLS] A [SEP] B [SEP],其中A 和 B 是两个句子。[MASK]只服务于掩码语言模型等训练目标,一般不会自然出现在下游输入中。[PAD]用于把同一批次中的序列补到相同长度。相应位置会在注意力掩码中记为 0,避免模型把填充值当作正文。
预训练目标#
掩码语言模型#
掩码语言模型(Masked Language Model,MLM)随机选择输入中的一部分词元,并要求模型根据双向上下文恢复这些词元。设被选中的位置集合为 \(\mathcal{M}\),原词元为 \(x_i\),被扰动后的序列为 \(\widetilde{\bx}\),则损失可写为
损失只在集合 \(\mathcal{M}\) 中的位置计算,而不是在所有输入位置计算。原始 BERT 从输入中选择 15% 的词元:其中 80% 替换为 [MASK],10% 替换为随机词元,另外 10% 保持不变。后两种情况可以减弱“预训练时总能看到 [MASK]、下游任务却从不出现它”所造成的分布差异。
图 33 MLM 根据未被遮盖的双向上下文预测选中位置的原词元。#
下一句预测#
原始 BERT 还使用下一句预测(Next Sentence Prediction,NSP)。正例由语料中相邻的句子 A 和 B 组成,负例中的 B 从其他位置抽取;模型读取 [CLS] 位置的最终表示,预测 B 是否在原文中紧接 A。原始训练数据中正、负样本各占一半。
图 34 NSP 是作用于句子对的二分类预训练目标。#
NSP 的准确表述是“判断两个文本片段在构造语料中是否相邻”,不能直接等同于学习因果关系、逻辑蕴含或完整篇章连贯性。后续预训练模型有的删除 NSP,有的使用不同的句间目标。这说明 MLM、模型结构、数据规模和训练方法都会影响表示质量,不能认定 BERT 的效果只由其中某一个训练目标造成。
预训练、特征提取与微调#
预训练得到参数初值 \(\btheta_0\),下游任务再利用带标签样本优化任务损失。常见迁移方式有两种:
特征提取或线性探测:冻结编码器,只训练新加入的分类输出层。这样显存需求较低、训练较快,但编码器无法针对目标任务调整表示。
端到端微调:分类输出层和编码器一起更新。它通常具有更强的适应能力,但更依赖学习率、批量大小、正则化和随机种子,也需要更多显存。
预训练权重可以看作一种信息充分的初始化,但不应未经建模就把它等同于贝叶斯分析中的先验分布。标准微调仍是对一个确定参数向量做优化;只有明确给出参数分布、似然与后验推断过程时,才构成贝叶斯模型。
对含有 \(C\) 个类别的分类任务,设 [CLS] 位置的最终表示 \(\bh_{\mathrm{CLS}}\in\mathbb{R}^{d}\) 为列向量,分类输出层计算
其中,\(\bW_{\mathrm{cls}}\in\mathbb{R}^{C\times d}\),\(\bb_{\mathrm{cls}},\bz\in\mathbb{R}^{C}\) 均为列向量。处理一个含 \(m\) 个样本的批次时,记第 \(i\) 个样本的输出列向量为 \(\bz_i\),则程序返回 \(\bZ=[\bz_1\trans;\ldots;\bz_B\trans]\in\mathbb{R}^{m\times C}\);这只是批量存储方式,并不改变单个向量默认为列向量的约定。
实验时应先从训练数据中划出验证集。训练集用于更新参数,验证集用于选择学习率、训练轮数和是否冻结编码器,测试集只在最终方案确定后评估一次。若根据测试集结果反复修改超参数,测试集便参与了模型选择,所得性能会偏乐观。
使用 BERT 进行情感分类#
下面以 IMDB 二分类为例,展示一个完整且较稳健的流程。示例使用 Auto 类以便更换兼容的检查点;使用 DataCollatorWithPadding 在每个批次内动态填充,避免把整个数据集都补到全局最长序列;使用 Trainer 统一管理训练、验证和最佳检查点。代码的主要输入是 bert-base-uncased 检查点以及 IMDB 中的评论文本和情感标签;运行完成后,会在 ./bert-imdb-output 保存训练检查点,并打印包含测试损失、测试准确率和运行时间等字段的字典。首次运行需要下载数据和模型,并安装 torch、transformers、datasets 与 accelerate。
1import numpy as np
2from datasets import DatasetDict, load_dataset
3from transformers import (
4 AutoModelForSequenceClassification,
5 AutoTokenizer,
6 DataCollatorWithPadding,
7 Trainer,
8 TrainingArguments,
9)
10
11checkpoint = "bert-base-uncased"
12raw = load_dataset("stanfordnlp/imdb")
13
14# 原测试集保留到实验结束;验证集只从原训练集划分。
15train_valid = raw["train"].train_test_split(
16 test_size=0.1,
17 seed=42,
18)
19splits = DatasetDict({
20 "train": train_valid["train"],
21 "validation": train_valid["test"],
22 "test": raw["test"],
23})
24
25tokenizer = AutoTokenizer.from_pretrained(checkpoint)
26
27def tokenize(batch):
28 # 不在这里 padding;collator 会按当前批次动态填充。
29 return tokenizer(batch["text"], truncation=True)
30
31tokenized = splits.map(
32 tokenize,
33 batched=True,
34 remove_columns=["text"],
35)
36data_collator = DataCollatorWithPadding(tokenizer=tokenizer)
37
38id2label = {0: "NEGATIVE", 1: "POSITIVE"}
39label2id = {label: index for index, label in id2label.items()}
40model = AutoModelForSequenceClassification.from_pretrained(
41 checkpoint,
42 num_labels=2,
43 id2label=id2label,
44 label2id=label2id,
45)
46
47def compute_metrics(eval_prediction):
48 logits, labels = eval_prediction
49 predictions = np.argmax(logits, axis=-1)
50 accuracy = np.mean(predictions == labels)
51 return {"accuracy": float(accuracy)}
52
53training_args = TrainingArguments(
54 output_dir="./bert-imdb-output",
55 learning_rate=2e-5,
56 per_device_train_batch_size=16,
57 per_device_eval_batch_size=16,
58 num_train_epochs=2,
59 weight_decay=0.01,
60 eval_strategy="epoch",
61 save_strategy="epoch",
62 load_best_model_at_end=True,
63 metric_for_best_model="accuracy",
64 greater_is_better=True,
65 report_to="none",
66 seed=42,
67)
68
69trainer = Trainer(
70 model=model,
71 args=training_args,
72 train_dataset=tokenized["train"],
73 eval_dataset=tokenized["validation"],
74 processing_class=tokenizer,
75 data_collator=data_collator,
76 compute_metrics=compute_metrics,
77)
78
79trainer.train()
80
81# 所有设计和调参完成后,只对测试集做最终评估。
82test_metrics = trainer.evaluate(
83 eval_dataset=tokenized["test"],
84 metric_key_prefix="test",
85)
86print(test_metrics)
程序先从原训练集中划出验证集,把原测试集保留到实验设计完成之后;随后分词、按批次动态填充,并对带二分类输出层的 BERT 微调两轮。compute_metrics 把每行 logits 中最大值的位置当作预测类别;最后打印的 test_accuracy 表示测试评论中预测正确的比例。具体数值会随软件版本、设备和训练随机性略有变化,不应把某次运行的日志当作固定输出。
from_pretrained 会载入 BERT 编码器的预训练参数,但新建的二分类输出层通常仍是随机初始化的。因此,在训练分类输出层之前直接报告分类准确率没有实质意义;那并不是“预训练 BERT 已经学会 IMDB 分类”的基准。合理的对照包括词袋逻辑回归、随机初始化后端到端训练的同结构模型、冻结编码器的线性探测,以及完整微调。
代码中的关键张量通常具有如下含义:
input_ids的维度为(batch_size, sequence_length),保存词元编号;attention_mask与input_ids同形,正文位置为 1,填充位置为 0;变量
logits保存输出层尚未经过 Softmax 转换的线性运算结果,维度为(batch_size, num_labels);矩阵的每一行都是一个样本的输出列向量转置后得到的 \(\bz\trans\);label保存类别编号。模型在收到标签后会内部计算交叉熵损失。
不应先对整个数据集使用 padding=True 再手工堆叠张量。评论长度差异很大时,全局填充会浪费大量计算;动态填充只把每个批次补到该批次的最长序列。若显存仍不足,可减小批量大小、使用梯度累积、混合精度,或改用较小的预训练模型。
只训练分类输出层#
若资源有限,可以在创建 Trainer 之前冻结编码器。下面的代码沿用前例的 checkpoint、id2label 和 label2id,创建新的分类模型,将基础编码器参数标记为不需要梯度,最后打印可训练参数数量与总参数数量。它只完成“创建并冻结”,还没有建立 Trainer 或开始训练:
1frozen_model = AutoModelForSequenceClassification.from_pretrained(
2 checkpoint,
3 num_labels=2,
4 id2label=id2label,
5 label2id=label2id,
6)
7
8for parameter in frozen_model.base_model.parameters():
9 parameter.requires_grad = False
10
11trainable = sum(
12 parameter.numel()
13 for parameter in frozen_model.parameters()
14 if parameter.requires_grad
15)
16total = sum(parameter.numel() for parameter in frozen_model.parameters())
17print(f"trainable parameters: {trainable:,} / {total:,}")
循环逐个将编码器参数的 requires_grad 设为 False,因而打印结果中的可训练数量应远小于总数,主要剩下分类输出层的参数。冻结操作必须发生在优化器或 Trainer 创建之前,否则优化器可能仍保留已经冻结的参数。线性探测时,Trainer 会令整个模型进入训练模式,编码器中的 dropout 因而仍会产生随机扰动。若要先缓存固定特征再训练分类器,应将编码器设为 eval 模式,并在 torch.no_grad() 环境中提取特征;缓存后再单独训练分类器。
冻结编码器并不保证比完整微调更好。比较这两种方案时,应使用相同的数据划分和评价指标;两种方案都在同一个验证集上尝试相同数量的超参数设置,并根据同一个评价指标选择。完整微调通常使用较小的学习率;在小数据集上,还可以逐步解冻顶层编码器、采用分层学习率或增加提前停止。
结果解释与使用边界#
训练结果会受到软件版本、硬件、批量大小、随机种子和数据划分影响,因此不能把某一次运行得到的精度写成必然结果。至少应说明如何使用验证集选择模型,并报告测试集指标、随机种子和检查点名称;还应使用多个随机种子重复运行,汇报结果的均值与波动程度。
BERT 能利用预训练语料中的统计规律,但这不意味着它真正理解事实,也不保证对新的领域、语言或人群公平。部署前应检查数据许可、类别不平衡、敏感信息、领域偏移和错误代价。对于医疗、法律等高风险场景,单一准确率尤其不足以支撑决策。
Shiny 交互演示:自注意力与 Layer Normalization
BERT 编码器以自注意力和 Layer Normalization 为基本组成部分。交互页面可以切换因果掩码、查看缩放点积得分与逐行 Softmax 权重,并逐位置核对 Layer Normalization 的均值、方差、标准化结果以及尺度和平移。理解 BERT 的双向编码时,应选择“不使用因果掩码”;页面中的随机矩阵只用于核对公式和维度,不是经过预训练的 BERT。
核心推导与实现核验#
核心关系
推导路径。 只在被选掩码位置计算分类负对数似然,模型利用双向上下文预测原词元;分母用有效掩码位置数,避免序列长度或掩码数改变损失尺度。
关键条件
若输入中未掩码的目标词元仍原样可见,模型可直接复制而不学习上下文;因此训练构造必须隐藏或扰动被预测位置。
数据规模
若一个批次有 \(m\) 条序列、每条序列统一为 \(T\) 个词元,则输入编号、片段编号和注意力掩码的大小通常都是 \(m\times T\)。BERT 为每个词元输出 \(d\) 个特征,因此编码结果为 \(m\times T\times d\);分类输出层的最后一维由任务的类别数决定。
常见误区
把 tokenizer 词表或模型预训练语料信息当作任务测试标签泄漏、忽略 padding mask、或错误解释 [CLS] 为固定语义都会造成偏差。
动手检查
只改变未被选位置的标签不应改变 MLM 损失;padding 与非掩码位置的梯度应为 0,并与框架 CrossEntropyLoss(ignore_index=...) 对照。
数值稳定性与规模
沿类别轴先减最大的线性运算结果,再计算 log-sum-exp;交叉熵直接接收输出层的线性运算结果。全被屏蔽的行必须事先排除,否则 masked softmax 会产生 NaN。
本节小结#
BERT 是使用双向自注意力的编码器。
MLM 损失只在被选位置计算。
输入掩码、池化方式和微调范围会直接影响实验含义。
综合练习#
程序题应固定随机种子、写出维度断言并报告运行环境。全部参考答案见 BERT 预训练与微调答案。
输入表示计算。 某词元位置的词元嵌入、片段嵌入和位置嵌入分别为列向量 \((1,0)\trans\)、\((0.5,0.5)\trans\) 和 \((-0.5,1)\trans\)。计算送入第一层编码器的表示,并证明三种嵌入必须具有相同维度才能按元素相加;说明相加后能否从结果唯一恢复三部分。
MLM 损失计算。 一个样本有两个被选位置,模型给原词元的概率分别为 \(0.5\) 和 \(0.25\)。计算平均 MLM 负对数似然;若同一批次的另一条序列没有被选位置,说明批量损失的正确分母应如何确定。
复制捷径论证。 假设所有被预测位置的原词元都未经扰动地保留在输入中,且编码器能够把当前位置词元编号传给输出层。构造一个不利用左右上下文也能使训练损失很小的预测规则,并据此论证遮盖或扰动目标位置的必要性;说明原始 BERT 中仍有少量目标词元保持不变为何不与该结论矛盾。
维度与内存计算。 对 \(m=16\)、\(T=128\)、\(d=768\)、\(H=12\)、类别数 \(C=2\) 的 BERT 分类批次,写出
input_ids、attention_mask、编码输出、单头查询和分类输出的维度。计算一层所有头的注意力得分元素数、单个 float32 得分张量内存,以及使用 \(d\) 维输入的线性分类输出层参数量。动态 masking 实现。 实现一个 MLM 数据整理函数:从非特殊、非 padding 位置中抽取 \(15\%\),按 \(80\%/10\%/10\%\) 分别替换为
[MASK]、随机普通词元和原词元;未被选位置的标签设为ignore_index。返回扰动输入、标签、注意力掩码和被选位置掩码,并检查每个至少含一个候选词元的样本都有训练目标。分类微调实现。 基于预训练编码器实现一个二分类流程,支持冻结编码器和完整微调两种模式。训练前正确设置
requires_grad和优化器参数组,使用注意力掩码、验证集早停和测试集最终单次评价;返回可训练参数量、每轮指标和固定批量预测函数。掩码与损失测试。 为第 5、6 题编写测试:未被选位置和 padding 的 MLM 标签均为
ignore_index;改变 padding 编号不影响有效样本预测;全 padding 或无 MLM 目标时明确报错;固定随机生成器可复现遮盖;手工 MLM 损失与库函数一致,并检查动态 masking 的长期经验比例接近设定值。词袋模型、冻结 BERT 与完整微调。 在同一文本分类数据上比较作为简单对照的词袋逻辑回归、冻结 BERT 编码器后只训练线性分类层的方法,以及完整 BERT 微调。各组使用相同的训练集、验证集和测试集划分、随机种子、原始训练样本和测试评价程序;为神经模型规定最多更新多少次,或者规定编码器最多运行多少次。报告准确率与 F1、训练时间、固定批量预测时间、峰值内存、参数量和编码器运行次数。
迁移方案比较。 比较冻结编码器、完整微调和一种参数高效微调方法。三种方法使用相同的检查点、词元化结果、数据划分、随机种子、最大更新次数和每次更新的原始样本数,并在同一个验证集上按照相同标准选择模型;报告预测质量、训练时间、固定批量预测时间、峰值内存、可训练参数量、总参数量和编码器前向传播与后向传播的运行次数。
输入长度与填充策略。 比较多个最大长度以及全局填充、批内动态填充两种策略。固定检查点、数据划分、随机种子集合、训练更新次数、每次更新样本数和评价代码;报告预测质量、训练时间、固定批量预测时间、峰值内存、截断比例、填充比例和编码器调用次数,并按原始文本长度分组分析误差。