“水很冷”和“他对人很冷”,用了同一个“冷”,意思却不同。区别就在前后文里。BERT 要学的也是这件事:同一个 token 放进不同句子,应该得到不同的表示,而不只是查出一个固定的词向量。
具体来说,输入有多少个 token,BERT 就输出多少个向量。每个向量对应一个位置,但经过编码器(encoder)后,也能包含其他位置的信息。这些向量还不是答案。接上任务输出层(task head),再用对应的标签训练,才可以给评论分类、给词打标签,或者从文章里找答案。
这些表示是怎么学出来的?BERT 的一个预训练(pre-training)任务,是把原文里的部分 token 藏起来,再根据剩下的文字预测它们。下面用 the tea is cold(茶是凉的)走一遍:先藏掉 tea,看看输入怎么变成向量,又怎么得到预测。
准备输入 → 转成向量 → 双向 encoder → 任务输出
做 MLM 时,输入是 [CLS] the [MASK] is cold [SEP]。Encoder 给每个位置生成带上下文的向量,再用选中位置的输出预测原词 tea。左边的 the、右边的 is cold 都可以提供线索。换成文本分类,则提供完整句子,接上读取 [CLS] 的分类 head 做微调(fine-tuning)。复用的是 encoder,不是 MLM 的词表(vocabulary)预测结果。
下面的静态图包含原始预训练的两个任务:MLM 和 NSP。开启 JavaScript 可以逐步查看计算路径;图解只说明结构,不运行模型。
不必第一次就把全部细节读完:
- 先弄懂它在做什么:看输入和向量、上下文怎样参与计算,再读评论分类。想知道它怎么回答问题,接着看选出答案片段。
- 准备写代码或查问题:重点看三种 mask、loss和 Python 检查。推导与代码可以按需展开。
本文讲的是 2018 年提出的原始 BERT;后来改过的做法会另外说明。
1. 为什么要先藏掉一个词?
假如原句完整地放在面前,我们却让模型回答“第 2 个位置是什么词”,它抄一个 tea 就行了,没必要学会读句子。把 tea 换成 [MASK] 后,这条捷径就没了:左边的 the 提供语法线索,右边的 is cold 提供更多语境,模型得把它们用起来。
不过,the [MASK] is cold 并不能唯一确定答案。填 tea 合理,填 coffee 也合理。训练仍然用原文里的 tea 作目标,通过交叉熵(cross-entropy)提高它的概率。模型是从大量例子里学习分布,不是在做每道都只有一个合理答案的填空题。
这也回答了另一个问题:没有人工标注,训练答案从哪儿来?原文自己提供答案。 我们先记住 tea,再改输入,就得到了一条训练样本。这类从数据自身构造监督的做法,通常叫自监督学习(self-supervised learning)。
到了实际任务,仍然需要任务自己的标签。会恢复原词,不代表已经知道一条评论该分成好评还是差评。BERT 的思路是先用大量文本学表示,再用较少的任务数据去适配,而不是每次从随机参数开始。BERT 原论文 §3
“双向”说的是每层 attention 都能同时利用左边和右边的输入,不是先从左读一遍、再从右读一遍,最后把两个结果拼起来。为什么允许这么读,还得看任务:
| 现在要做什么 | 可用信息 | 模型需要学什么 |
|---|---|---|
| 给已有的一段话分类 | 整段输入已经给定 | 综合前后内容做判断 |
| 从文章里找答案 | 问题和文章已经给定 | 定位答案所在的位置 |
| 继续生成下一句 | 后面的文字尚不存在 | 只根据已有前缀生成 |
前两种任务的完整输入已经在手上,当然可以一起读;续写时,后半句还没生成,就只能根据已有前缀预测。这个区别比“encoder 和 decoder 哪个更好”更有用。BERT 原论文主要研究的是前一类文本理解任务。
那 BERT、LSTM 和 Decoder-only 到底差在哪儿?
这几个名字不在同一个层面。LSTM 是递归单元;BERT 是 encoder 架构加一套预训练方法;Decoder-only 说的是架构,常见训练目标是自回归预测。
| 比较哪一件事 | LSTM / BiLSTM | 原始 BERT | 常见自回归 Decoder-only |
|---|---|---|---|
| 怎样交换信息 | 沿序列更新状态;BiLSTM 分两个方向 | 双向 self-attention | 带 causal mask 的 self-attention |
| 哪些计算可并行 | 可并行样本,但普通递归更新依赖同方向的前一步 | 一层内的全部位置可一起算,层之间仍有依赖 | 训练中已给定目标文本时可一起算;常规生成仍逐 token |
| 原生训练目标 | LSTM 本身不规定 loss | MLM + NSP | Next-token cross-entropy |
| 读取右侧信息 | BiLSTM 可以,单向 LSTM 不可以 | 可读取扰动输入的右侧 | 生成时不可读取未来输出 |
比如分类“服务不算快,但味道很好”:BiLSTM 和 BERT 都能利用后半句,不是只有 attention 才能双向。区别在于计算路径——普通 LSTM 要沿状态链传递信息,full self-attention 允许远处位置在一层内直接交互,但也增加了随长度增长的成对计算。并行更方便,不等于任何长度和硬件上都更快。Transformer §4
再换成续写“服务不算快,但”:此时后半句根本不存在。能用完整句子做分类,和能从前缀生成,是两种任务。可以让 LSTM 训练 next-token prediction,也可以让 encoder 做分类;不要把训练目标当成某个基础组件自带的属性。递归计算细节见 RNN / LSTM。
在当时,预训练和迁移已经有人在做。2018 年的 ULMFiT 先训练 LSTM 语言模型(language model, LM),再适配到目标语料和分类任务;同年的 ELMo 用双向 LSTM 语言模型提供上下文特征。把它们放在一起看,更容易理解 BERT 改了什么:深层双向 attention、适合它的预训练目标,以及同一个 encoder 适配多种任务的方式。
“能读左右文”也不等于“能读任意长的文档”。原始 BERT 的输入上限为 512 个 token,完整 attention 的计算还会随长度平方增长。比如关键信息在被截掉的第 600 个 token,双向 attention 也看不到它。处理长文还得考虑分块、检索(retrieval)或经过验证的长上下文模型。BERT 附录 A.2
2. 一条输入怎样走过模型?
先分词,再补上边界
原始 BERT 使用 WordPiece,把文字拆成词表里已有的 token,再转成整数 ID。一个词可能对应多个 subword,所以“句子有 10 个词”不一定等于“模型看到 10 个位置”。分词的细节可以回看 Tokenization。
一段文本会整理成 [CLS] A [SEP],文本对则是 [CLS] A [SEP] B [SEP]。[CLS] 放在最前面,后面可以用它的表示做分类;[SEP] 标出一段文字的结束。它们也有自己的 ID 和 embedding,会和普通 token 一起进入模型。
三个 embedding,各补一种信息
光知道当前位置是哪个 token 还不够,还要知道它在哪儿、属于哪段文本:
| 向量 | 回答什么问题 | 在例子里是什么 |
|---|---|---|
| Token embedding | 这里是什么 token? | 查到 tea 的向量;遮盖后则查 [MASK] 的向量 |
| Position embedding | 它在第几个位置? | [CLS] 从 0 开始,tea 在位置 2 |
| Segment embedding | 它属于哪段文本? | 单段用 A;文本对用 A、B 区分两段 |
三个向量维度相同,直接逐维相加,再经过 LayerNorm 和 dropout,进入 encoder。比如只看 3 个维度,[1, 0, 2] + [0, 1, 1] + [1, 1, 0] = [2, 2, 3],结果仍是 3 维。这只是说明相加方式的手造数字;BERT-Base 的 hidden size 是 768。原始 BERT 的位置向量是学出来的绝对位置 embedding,不是 RoPE。原文 §3、Embedding 实现
例如输入“明天有雨”和“记得带伞”,第一段及其 [CLS]、[SEP] 用 segment A,第二段及末尾 [SEP] 用 segment B。位置编号沿整条输入递增,不在 B 的开头重新从 0 算。两段虽然有边界,仍然能通过 self-attention 互相读取;这里没有另外接一个 decoder 的 cross-attention 子层。
输入的形状没变,表示却在变
同一个 token 在 embedding 表里查到的是同一行。进入 encoder 后,它的表示会结合当前句子重新计算。例如把 the tea is cold 换成 the weather is cold,cold 的 token embedding 没变,但它接收到的上下文变了,最后的向量也可以不同。查表得到的是起点,结合上下文算出的 hidden state 才是后面要用的表示。
| 计算走到哪儿 | 教学例子:batch 为 2、补齐到 7 个位置、hidden size 为 8 |
|---|---|
| Token IDs | [2, 7],整数索引 |
| Embedding 相加 | [2, 7, 8],不因相加变成 24 维 |
| 每层 encoder | 仍是 [2, 7, 8],不同位置交换信息,再做逐位置 FFN |
| 最后一层 hidden states | 每个位置都有一个 8 维表示,尚不是分类概率 |
| 任务输出层 | 按任务变成词表分数、类别分数或答案位置分数 |
表里用小尺寸方便数清楚。BERT-Base 实际会输出 [batch, length, 768],并不会自动把整句话压成一个向量;需要句子向量时,还要选择如何读取或汇总这些位置。原始 block 的 residual、LayerNorm 与 FFN 可以沿着原版 Transformer继续看。
“结合上下文”,具体算了什么?
先只看一个注意力头(attention head)、一个输出位置。它会给可见位置分别算一个权重,再把这些位置负责传递内容的 value 向量按权重加起来。这里的权重回答的是“这次从每个位置读多少信息”,不是某个词永远有多重要。
用 3 个位置、每个 value 只有 2 维的小例子,假设 softmax 后的权重已经算好:
- 位置 A · 权重 0.2[1, 0] × 0.2传入 [0.2, 0]
- 位置 B · 权重 0.3[0, 2] × 0.3传入 [0, 0.6]
- 位置 C · 权重 0.5[2, 1] × 0.5传入 [1, 0.5]
把三份相加,这个注意力头在当前位置的输出就是 [1.2, 1.1]。先固定权重,只把位置 A 的 value 从 [1, 0] 改成 [3, 0],输出就变成 [1.6, 1.1]。别的位置变了,当前位置收到的信息也跟着变。 真实模型换了上下文,通常不止 value 会变,用来计算权重的向量也可能一起变。
那权重从哪儿来?当前位置用自己的查询向量(query),和各位置的键向量(key)算相似度,再经过缩放和 softmax。多个注意力头分别做这件事,结果再拼接、投影。因此上面算出的 2 维向量只是一个头的结果,还不是整层输出;后面还有残差、归一化和 FFN。机制见 Transformer §3.2。
展开一层:attention、残差和 FFN 按什么顺序?
原始 BERT 是 post-LN。设这一层输入为 $H$,先做双向 multi-head attention,再做逐位置 FFN。为方便分步看,下面用 $D$ 表示 dropout,$\operatorname{LN}$ 表示 LayerNorm:
$$ \begin{aligned} A&=D(\operatorname{MHA}(H)),\\ U&=\operatorname{LN}(H+A). \end{aligned} $$
$$ \begin{aligned} F&=D(\operatorname{FFN}(U)),\\ H'&=\operatorname{LN}(U+F). \end{aligned} $$
这里的 MHA 包含各头拼接后的输出投影;FFN 是 dense → GELU → dense。每个子层都有自己的残差相加与 LayerNorm,不是整个 block 最后只加一次。原版 Base 堆 12 层,Large 堆 24 层;token / segment / position embedding 只在进入这串 block 前相加,不是每层重新加一遍。结构可以对照原始实现的 transformer_model。这也不同于许多现代 decoder 的 pre-norm、RMSNorm 与 SwiGLU,不能直接拿后者的 block 图代替。
上面的加权汇总也可以直接算,不需要下载模型。这里指定的是缩放后的 attention scores,取 log(2)、log(3)、log(5),softmax 就会得到 0.2、0.3、0.5:
import math
scores = [math.log(2), math.log(3), math.log(5)]
exp_scores = [math.exp(score - max(scores)) for score in scores]
weights = [score / sum(exp_scores) for score in exp_scores]
values = [[1.0, 0.0], [0.0, 2.0], [2.0, 1.0]]
def mix(current_values):
return [sum(weight * value for weight, value in zip(weights, column))
for column in zip(*current_values)]
before = mix(values)
values[0] = [3.0, 0.0]
after = mix(values)
assert all(math.isclose(actual, expected)
for actual, expected in zip(before, [1.2, 1.1]))
assert all(math.isclose(actual, expected)
for actual, expected in zip(after, [1.6, 1.1]))
print([round(value, 2) for value in before])
print([round(value, 2) for value in after])
这段代码只验证一个 head 的加权求和,不包含 Q/K/V 投影、dropout 或训练。
3. 三种“mask”,其实管三件事
代码里经常同时出现 [MASK]、attention_mask 和忽略标签,初看很容易混在一起。可以把它们分别理解为:输入改了哪里、attention 能看哪里、最后给哪里算分。 它们不是同一张表。
输入遮盖:改了模型看到的内容
MLM(masked language modeling)先选预测位置,再决定如何扰动。原论文使用约 15% 的预测比例;在选中的位置中,80% 换成 [MASK],10% 换随机 token,10% 保持原样。无论替换成什么,目标仍是原 token。 [CLS]、[SEP] 和 padding 不作为普通预测位置。原始数据构造代码
假设一批数据已经选出 150 个目标,按比例约有 120 个走 [MASK] 分支、15 个随机替换、15 个保持原样。这是期望数量,不保证每批都恰好满足;随机抽到的 token 也可能碰巧等于原 token。
读原始代码时,15% 的分母要留意一下
代码按尚未 padding 的 len(tokens) 算预测数量,这个长度包含 [CLS] / [SEP];数量会取整、至少取 1,并受 max_predictions_per_seq 限制。随后从排除了结构 token 的候选(candidate)位置中选择。因此它并不等于“每个普通位置独立以 15% 概率入选”。例如 8 个普通 token 加上 [CLS] / [SEP],长度是 10;上限允许时,round(10 × 0.15) 每次都得到 2 个目标;如果每个普通位置各自以 15% 独立入选,8 个位置平均只有 1.2 个,而且每次的数量会波动。
仓库后来还加入了 whole-word masking 选项,按 ## 子词(subword)组成的组选择;不要把这个可选分支当作原版所有 checkpoint 的训练方式。实际复现时,应固定版本、采样规则和随机状态。
保留原样的目标也算 loss。模型并不知道这次哪些未改动的 token 被选中了。混入随机替换和原样输入,是为了缓解预训练里经常见 [MASK]、下游却通常见不到它的差别,不意味着这个差别完全消失了。
选哪些位置,也会改变模型需要利用的线索。2019 年百度的 ERNIE §3.2 尝试按短语、实体整体遮盖。拿“纽约市位于美国”来说,只遮“约”,还能从“纽”和“市”猜;把“纽约市”一起遮住,就更需要外部上下文。这是早期 ERNIE 对训练样本的改动,和后来同名聊天模型的架构是两回事。
Attention mask:哪些位置可以被读取
双向 encoder 可以读到当前 token 的两边。下面只画有效 query 行,1 表示允许读取:
| Query \ Key | tea |
[MASK] |
again |
[PAD] |
|---|---|---|---|---|
tea |
1 | 1 | 1 | 0 |
[MASK] |
1 | 1 | 1 | 0 |
again |
1 | 1 | 1 | 0 |
[MASK] 是一个正常参与计算的输入 token,不是 attention 里的负无穷。Padding key 被排除;padding query 是否仍产生非零 hidden state 是实现问题,不能因此把它计入 loss 或 pooling。独立样本如果 packed 到一起,还需要阻断跨样本读取,不能只放一个分隔符就当作隔离了。
补齐序列和生成 padding mask 是两步:前者添加占位 token,后者告诉 attention 哪些位置是占位。序列也不必总补到模型最大长度,常见做法是补到当前 batch 的最长样本。至于 causal mask,它限制的是前后可见性,见三处 attention 的对照。
Loss mask:哪些输出要被评分
用一个手工词表看得更清楚:PAD=0, CLS=1, SEP=2, MASK=3, the=4, tea=5, is=6, cold=7, hot=8。这不是实际 WordPiece 词表。特意选第 2、4 个位置(从 0 开始),让 tea 保持不变、cold 变成 [MASK]:
| 字段 | 数值 |
|---|---|
| 原始 token IDs | [1, 4, 5, 6, 7, 2, 0] |
| 模型输入 | [1, 4, 5, 6, 3, 2, 0] |
| Attention mask | [1, 1, 1, 1, 1, 1, 0] |
| Labels | [-100, -100, 5, -100, 7, -100, -100] |
这里用 -100 作为教学实现的忽略标记,这是 PyTorch 交叉熵常见的约定,不是词表中的一个词,也不是原始 TensorFlow 数据格式。只有一个 [MASK],却有两个监督位置。不能用 input_ids == mask_token_id 来代替 loss mask。 小例子固定选了两个位置,只为说明字段关系,不是在模拟 15% 采样。
4. Loss 算在哪儿?
在上一节的例子里,训练只检查两个位置的预测:一个是 tea,一个是 cold。其余位置提供上下文,但不直接计算 MLM loss。
设本 batch 选中的位置集合为 $M$,原 token 为 $x_i$,扰动后的整段输入为 $\tilde x$。MLM 的一个常用聚合方式是:
$$ L_{\mathrm{MLM}}=-\frac{1}{|M|}\sum_{i\in M}\log p_\theta(x_i\mid\tilde x). $$
每个待预测的位置,会先给词表中的各个 token 打分,得到 logits,再用交叉熵检查原词的概率。labels 和输出位置一一对应:第 2 个位置恢复第 2 个位置的原词,不像 next-token prediction 那样把答案错开一格。
实现上,原始 MLM head 先取出目标位置的 hidden states,经过 dense + activation + LayerNorm,再用与输入 embedding 共享的词表矩阵投影,并加 bias。第一次读可以先记住输入输出:一个位置的表示,变成词表中每个 token 的分数。原始实现
原 TensorFlow 实现用 masked_lm_positions、masked_lm_ids 和 masked_lm_weights 表示监督;补齐的预测槽位权重为 0,分母是权重和加 1e-5。下文手算与教学代码使用严格的有效目标均值,不逐位复刻这个 epsilon。
假设两个目标的正确 token 概率分别为 0.8 和 0.25:
$$ \begin{aligned} L&=\frac{-\log0.8-\log0.25}{2}\\ &\approx0.804719. \end{aligned} $$
如果误除以包含 padding 的 7 个位置,就会得到约 0.229920:预测没变好,只是分母变大了。这个例子里该数的是两个有效目标,补几个 [PAD] 不应该改变答案。教学代码遇到零个目标会报错,方便尽早发现数据问题。
一次训练,实际做了哪几步?
把数据和 loss 接起来,一次更新可以这样看:
- 留好答案,再改输入。 保存原 token,选预测位置,构造扰动后的输入和 labels。
- 整段经过 encoder。 所有有效位置一起计算 hidden states;选中位置的 MLM head 再给词表打分。
- 拿原词算 loss。 对有效目标算交叉熵,再做反向传播,更新 embedding、encoder 和 MLM head。原始 BERT 还会加入下一节的 NSP loss。
- 下一步用更新后的参数。 换一个 batch 继续训练;只有实际执行参数更新,模型才会逐渐改变预测。
因此,15% 的预测位置不等于只做 15% 的计算。被选中的词需要其他词提供上下文,encoder 仍要处理整段输入;节省的是只对一部分位置计算词表输出的那部分工作。原始训练实现
同一句话也可以出成不同的题:这次藏 tea,下次藏 cold。原始实现会预先生成若干遮盖版本并保存;RoBERTa 改为在送入模型时生成 masking。两者的区别在于题目何时产生、能怎样变化,不是“一个有随机性、另一个没有”。RoBERTa §4.1
动手看看:换输入、加 padding,loss 会怎样变?
输入换了,答案没换。
选中 cold 以后,三种扰动方式如下。即使输入保持原样,也仍然要预测 cold:
| 分支 | 模型输入 | 目标 |
|---|---|---|
| 遮盖 | [MASK] |
cold |
| 随机替换(假设这次抽到 hot) | hot |
cold |
| 保持原样 | cold |
cold |
手工设定两个正确目标的概率:tea 为 0.80,cold 为 0.25。按两个目标平均,loss 约为 0.805;增加 padding 不会改变它。开启 JavaScript 可以切换输入、调整概率,看看哪些量会变。这里只计算算式,不运行模型。
没有直接算 loss 的位置,还会学到东西吗?
会。未选中的位置仍可为目标位置提供上下文;目标的 loss 能沿 attention 路径传回它们。忽略某个位置的输出评分,不等于切断这个位置在整个模型里的梯度。
对目标位置 $i$ 的某个词表 logit $z_{i,v}$,上面这个平均 loss 有
$$ \frac{\partial L}{\partial z_{i,v}} =\frac{p_{i,v}-\mathbf 1[v=x_i]}{|M|}. $$
未选中位置的输出 logits 不直接进入该 loss;但共享参数和上下文 hidden states 仍可能有梯度。要区分“这个输出没被评分”和“这段计算没有参与学习”。
5. NSP:两段文字是不是接着写的?
原始 BERT 还训练 next sentence prediction:给两段文本,判断第二段是否实际接在第一段后面。样本一半是相邻段,一半是随机配对,输出是二分类,而不是下一句的 token。原文 §3.1
这里的“sentence”可以是一段连续文本,不一定只有一句话。NSP head 读取 [CLS] 的 pooled 表示,原实现把 MLM loss 与 NSP loss 相加后反向传播。
比如原文写着“开始下雨了。我收起晾在外面的衣服”,把相邻两段取出来,就是正例。如果第二段换成从别处随机抽到的“矩阵乘法满足结合律”,就是负例。标签看的是它们在语料中是否相邻,而不是我们觉得接得顺不顺:随机抽来的段落,也可能碰巧很搭。
2019 年的 RoBERTa 重新检查了 masking、输入组织、NSP 和训练规模。它采用动态 masking,并在调整后的输入配方里不再使用 NSP。读消融(ablation)时要注意:输入如何拼接也在变化,不能将全部收益归给“删了一个 loss”。RoBERTa §4
所以,看到一个后来发布的 encoder,可以分别问它用了什么结构、怎么组织数据、用什么目标训练。模型长得像 BERT,不意味着训练时一定做过 NSP。
6. 预训练结束后,怎么换成自己的任务?
假设我们已经下载了一个预训练 checkpoint,接下来想判断评论是好评还是差评。这时给模型的是完整评论,不需要像 MLM 那样先把词藏起来。训练答案也从“原词是什么”,换成了“这条评论是什么类别”。
先做一个评论分类器
以“茶凉了,等了很久”为例,我们希望模型输出负面类别。Encoder 仍然给每个位置输出向量,分类层读取 [CLS] 的表示,把它转成类别分数。[CLS] 不是预先写好的整句平均值;它通过 attention 接收其他位置的信息,再在训练中学会保留任务需要的内容。原始分类代码还会对它做一次 dense + tanh pooling。分类实现、Pooler 实现
记 pooled 表示为 $h_{\mathrm{CLS}}$,分类层的参数为 $W,b$,则
$$ \begin{aligned} z&=W h_{\mathrm{CLS}}+b,\\ p&=\operatorname{softmax}(z). \end{aligned} $$
BERT-Base 的表示是 768 维,二分类的 $W$ 就是 [2, 768],输出两个 logits。假设一次输出是 [1, 3],类别顺序为 [正面, 负面]。用 $p_-$ 表示负面类别的概率:
$$ \begin{aligned} p_-&=\frac{e^3}{e^1+e^3}\\ &\approx0.880797,\\ L&=-\log p_-\approx0.126928. \end{aligned} $$
这是方便手算的假设分数,不是模型实测。注意这次 softmax 比较的是两个类别,MLM 比较的则是整个词表。Encoder 可以复用,输出到底意味着什么,取决于新接的 head 和标签。
换成问答,读哪些位置?
文章写着“会议改到了周五下午。”你问“会议改到什么时候?”答案已经在原文里,模型不用重新写一句话,只需要找出“周五下午”从哪里开始、到哪里结束。这就是抽取式问答(extractive QA)。
问题和文章一起进入 BERT,文章的表示因此也会受到问题影响。问答输出层在每个位置给出两个分数:这里像不像答案的起点(start),像不像终点(end)。起点和终点各有一次 softmax,比较的是位置,不是词表。
为了看清位置,先把这句原文手工分成 6 格:会议 / 改到 / 了 / 周五 / 下午 / 。,从 0 编号。这只是示意分块,不是实际 WordPiece 分词。假设模型给出了下面的分数:
- 起点 · 位置 3周五 · 分数 4从这里开始读。起点分数比较的是“答案从哪里开始”。
- 终点 · 位置 4下午 · 分数 5读到这里结束,包含这一格。终点分数单独计算。
这个候选片段的总分是 4 + 5 = 9。在下面设定的完整分数里,它是合法片段中分数最高的,所以取出位置 3 到 4,得到“周五下午”。训练时,则用标注的起止位置监督这两个预测。原论文 §4.2
不能随手各取一个最高分就结束。 如果最高起点在位置 4,最高终点却在位置 3,这个范围是倒着的。还要排除问题、特殊 token 和 padding;需要时限制答案长度。下面的小程序只做这一步选择,不运行 BERT。
用 Python 选片段:别让答案倒着来,也别跨过无效位置
import math
def best_answer_span(start_scores, end_scores, allowed, max_length):
length = len(start_scores)
if length != len(end_scores) or length != len(allowed):
raise ValueError("Scores and mask must have the same length")
if type(max_length) is not int or max_length < 1:
raise ValueError("max_length must be a positive integer")
if any(type(value) is not bool for value in allowed):
raise ValueError("allowed must contain booleans")
if not all(math.isfinite(score) for score in [*start_scores, *end_scores]):
raise ValueError("This example expects finite scores")
best = None
for start in range(length):
for end in range(start, min(length, start + max_length)):
if not allowed[end]:
break
score = start_scores[start] + end_scores[end]
if best is None or score > best[0]:
best = (score, start, end)
return best
start_scores = [0, 0, 0, 4, 1, -2]
end_scores = [0, 0, 0, 1, 5, -2]
answer = best_answer_span(start_scores, end_scores, [True] * 6, 3)
assert answer == (9, 3, 4)
print(answer)
allowed 标出哪些位置可以出现在答案里,max_length 是最多几格。循环只考虑终点不早于起点的连续片段,遇到无效位置就停止延伸。同分时保留先遇到的片段;这里没有计算概率,也没判断答案是否真实。若长度为 $L$、最大答案长度为 $A$,枚举成本为 $O(L\min(L,A))$;教学数据很短,不必先做复杂优化。
真实分词不能靠把 token 字符串直接拼回去。应保存每个 token 对应原文的字符范围,再从原文切出答案,这样空格、标点和子词才不会被弄坏。Transformers 的问答示例用 offset mapping 和 sequence IDs 处理这些对齐。
如果改问“会议在哪个房间?”,这段原文就没有答案。但只要还存在可选片段,上面的程序仍会挑出一个。能选出最高分,不代表原文真的回答了问题。 无答案任务需要相应训练标签与拒答判断;原始 BERT 在 SQuAD 2.0 中用 [CLS] 表示空答案,并在开发集上选择判断阈值。原论文 §4.3
也别把“原文没答案”和“答案被截断了”混为一谈。前者需要拒答,后者要先检查文档分块和输入长度。若想把原文改写成一句解释,则已经是生成式问答的需求,不能只靠这个选位置的输出层。
把几种任务并排看,就很容易分清 head 在做什么:
| 任务 | 读取哪里 | 输出与训练数据 |
|---|---|---|
| 文本分类 | [CLS] 的聚合表示 |
每条文本的类别 logits 与标签 |
| 命名实体识别 | 每个相关 token 的表示 | token 级标签;先处理词与 subword 对齐 |
| 抽取式问答 | 文中每个候选答案位置 | start / end logits;排除问题和 padding 等非答案位置 |
| 成对相关性打分 | query 与 document 联合编码 | 一对文本的分数与相关性标签 |
| 单向量检索 | 两边分别编码,再 pooling | 还需合适的表示学习目标与检索评估 |
Encoder 要不要一起训练?
如果只想先跑通任务,可以冻结 encoder,只训练分类层。这样不用保存 encoder 反向传播所需的中间激活;输入固定、关闭 dropout 时,还能提前算好特征。但如果原来的表示没有保留你需要区分的信息,一个小分类层能补的也有限。
全参数微调则允许 encoder 跟着任务一起调整,代价是更多训练显存和计算,也更容易在小数据上过拟合(overfitting)。原论文采用端到端微调,并用开发集选择学习率;小数据上的结果还会受分类层初始化和数据顺序影响。BERT §3.2、§4.1
比较这两种做法时,用同一份数据划分,记录任务分数、训练成本和不同 seed 的变化。不要先假定“全部解冻肯定更好”,再只保留最好的一次结果。
为什么检索还要分双塔和 cross-encoder?
如果将 query 和 document 一起送进 BERT,双方可以在每层交换信息,适合仔细判断一对文本是否相关。但 document 的表示已经依赖这次 query,不能提前算一次供所有 query 复用。假设有 10 万篇候选文档,每次请求都这样做,就要评估 10 万个文本对;可以批处理,却不能省掉这些配对计算。
双塔(dual-encoder)让 query 和 document 分别编码,文档向量就能提前计算和索引,检索时更省事;代价是两边编码时不能直接交换 token 级信息。常见组合是先用向量召回一小批,再用 cross-encoder 精排(ranker)。为什么适合余弦相似度的句向量需要专门训练,可以读 Sentence-BERT(2019);系统怎么拆,见 双塔检索和 BGE-M3 / Qwen3 Embedding。
7. 跑一次,检查最容易弄错的字段
教学脚本只依赖 Python 标准库,固定替换结果,不下载模型,也不进行预训练:
python3 00-foundations/code/mlm_contracts.py
Input IDs: [1, 4, 5, 6, 3, 2, 0]
Labels: [-100, -100, 5, -100, 7, -100, -100]
Attention: [1, 1, 1, 1, 1, 1, 0]
Selected-token loss: 0.804719
corruption_action 用一次均匀随机数演示选中之后的 80/10/10 分支;原始代码使用条件式的两次抽样,概率一致,但相同 seed 不保证相同结果。build_example 接收明确的替换位置,保留原标签;masked_cross_entropy 只对选中的位置取平均。它不是随机 collator,也没有 attention、任务 head 或反向传播。代码完整保留了“未变但被选中”的情况,方便与只数 [MASK] 的错误实现对照。
可以只改一个条件试试:
| 你改了什么 | 应该观察到什么 | 在检查什么 |
|---|---|---|
追加 padding,labels 仍为 -100 |
Loss 不变 | 是否只对有效目标取平均 |
| 把忽略位置的 logits 改得很大 | Loss 不变 | 没有标签的位置是否混入计算 |
| 提高正确目标的 logit,其他不变 | Loss 下降 | 目标 ID、维度和交叉熵方向是否正确 |
这些检查通过,只说明数据与 loss 的关系对了。接上真实模型后,还得确认梯度和参数确实在更新,最终再看任务的验证集表现。
8. 真正接数据时,还要检查什么?
先做一个足够小、能看懂结果的实验。比如让模型拟合几十条训练样本:如果连它们都学不好,优先检查标签、mask、梯度和优化器;能拟合以后,再看独立验证集。这个小实验是排错工具,不是泛化能力的证明。
| 现象 | 先检查哪里 |
|---|---|
| Loss 低得不正常 | 原答案是否泄漏、是否除以了总长度、是否只统计未扰动的位置 |
| 不同 batch 的 loss 忽高忽低 | 有效目标数、长度分布、样本重复和采样是否一致 |
| MLM 学得好,分类却不好 | 标签质量、数据分布、任务 head 与微调方案;预训练 loss 不是任务指标 |
| 离线分数很好,上线掉很多 | 训练与评估是否共享近重复文本、截断是否删了关键内容 |
分类指标也要结合数据看。假设验证集里有 95 条好评、5 条差评,一律预测“好评”也有 95% 准确率(accuracy),但差评召回率(recall)是 0%。如果你关心的是找出投诉,这个模型几乎没帮上忙。除了总分,至少看看各类别的精确率和召回率,再读几条分错的评论;按用户、来源或时间划分数据,取决于你真正要泛化到什么场景。
分错了一条评论,先别急着换模型
比如“包装很好看,但茶已经坏了”被分成了好评。先打印实际送进模型的 token:如果截断后只剩“包装很好看”,模型根本没读到后半句,先改输入处理,而不是继续调学习率。
如果整句都在,再检查类别编号:训练时 0 是差评,展示时却把 0 当成好评,也会让结果看起来完全不对。输入和映射都没问题,才继续看模型是否过度依赖“很好看”这样的局部词。
可以补两条小检查:“茶已经坏了”和“茶没有坏”。它们能帮你观察模型对否定词是否敏感,但两条都答对,仍不能说明它处理好了所有否定、转折和反讽。小例子用于定位问题,正式结论还要回到独立测试集。
加载 checkpoint 时,把 tokenizer、特殊 token、label 映射和截断长度一起保存。模型能正常输出,不代表这些配置就匹配。验证 masking 时固定样本和随机种子;比较模型时用独立验证集,记录采样方式,再检查长度、类别和来源不同的样本。
读到这里,可以把 BERT 连成一条完整的路:从原文构造训练题 → 用上下文学每个位置的表示 → 接上任务输出层 → 用任务数据适配和评估。 接下来读 Decoder-only,看看目标变成“继续往下写”以后,哪些地方需要跟着变。
核对日期:2026-10-10。本文的数值是教学算例;代码检查覆盖局部 attention、数据与 loss、答案片段选择,未复现 BERT 预训练或论文分数。
本页术语 14
- 编码器 encoder
- 把原始输入压成向量的那一半模型
- 预训练 pre-training
- 先用大量数据学习通用表示和能力,再适配具体任务
- 微调 fine-tuning
- 在预训练权重上继续训练以适配具体任务
- 词表 vocabulary
- 模型能输出的所有 token 的集合
- 交叉熵 cross-entropy
- 分类任务的标准损失,−log 正确类的概率
- 自监督 self-supervised
- 从数据自身结构造出监督信号,不依赖人工标注
- 语言模型 language model, LM
- 对文本序列建模,用上下文预测或生成后续内容
- 检索 retrieval
- 从外部语料里找出相关内容
- 候选 candidate
- 召回阶段捞出来、等待精排挑选的内容
- 子词 subword
- 介于完整单词和字符之间的可复用字符串片段
- 消融 ablation
- 去掉一个组件再测一次,用来判断它到底有没有贡献
- 过拟合 overfitting
- 训练集越来越好,没见过的数据反而变差
- 双塔 dual-encoder
- 用户和内容各一个编码器,映到同一向量空间靠内积召回
- 精排 ranker
- 第二阶段,对少量候选做充分交互后精细排序