6. BERT:看懂句子的编码器
这是什么:BERT(Bidirectional Encoder Representations from Transformers,双向编码器表示) = Google 2018 年用 Transformer 编码器造出的"阅读理解模型"——只看不说,专门把句子读成一个懂上下文的向量。
为什么用:前五章一路按 GPT(解码器)的玩法教模型"说下一个词";但编码器是同一张图纸的另一半玩法,文本分类、相似句检索、问答底座里至今常见。认识它,GPT 与 BERT 才算配齐。
如何用:先搞清编码器与解码器的核心差异,再走完 BERT 的完整生命周期:预训练输入侧 → 挖空预测输出侧 → 下一句预测 → 后训练微调。
编码器 vs 解码器:一眼收全 vs 逐词输出
同一份 Transformer 图纸,两种装法:
| GPT(解码器玩法) | BERT(编码器玩法) | |
|---|---|---|
| 注意力范围 | 只看左边——读到哪个词才能看哪个词 | 整句一起看——每个词一眼收全所有词 |
| 训练任务 | 猜下一个词(文字接龙) | 完形填空(遮词填空) |
| 擅长 | 写:续写、对话、生成 | 读:分类、检索、理解、打标签 |
BERT 的双向注意力正是第 3 章那套"每个词看所有词"的原版用法,不加因果遮罩;而句首多出的那个位置叫 [CLS](classification,分类哨兵)——整句读完,它的向量就充当"本句摘要",下游任务直接拿它用。
预训练任务一:完形填空(MLM)的输入侧
BERT 的第一件本事靠掩码语言模型(MLM,Masked Language Model):随手遮住句子里约 15% 的词,让模型看着双向上下文填空。输入侧的构造如下:
vocab = {"<PAD>": 0, "[CLS]": 1, "[SEP]": 2, "<挖空>": 3,
"猫": 4, "吃": 5, "鱼": 6, "了": 7}
sentence = ["猫", "吃", "鱼", "了"]
# BERT 输入惯例:句首加 [CLS] 哨兵,句尾加 [SEP] 分隔符
seq = [vocab["[CLS]"]] + [vocab[w] for w in sentence] + [vocab["[SEP]"]]
print(seq)
# 挖空:把"吃"替换成挖空记号(真实预训练随机选约 15% 的词)
masked = seq.copy()
masked[2] = vocab["<挖空>"] # 下标 2 正是"吃"
print(masked)
你会看到:
[1, 4, 5, 6, 7, 2]
[1, 4, 3, 6, 7, 2]
| 代码 | 作用 |
|---|---|
[CLS] = 1 / [SEP] = 2 |
特殊片段:句首哨兵与分隔符,词表固定的第 1、2 号常客 |
seq |
BERT 式输入:[CLS] 猫 吃 鱼 了 [SEP] → [1,4,5,6,7,2] |
masked[2] = vocab["<挖空>"] |
挖空"吃":训练时模型要靠左右两边把它猜回来 |
| 两个输出一对照 | 第 3 个位置从"吃"(5) 变成了挖空记号 (3) |
预测:挖空处的答案怎么算出来
挖空之后,预测的输出侧正是第 3 章学过的老三样:编码结束后,挖空位置的输出向量与词表里每个词的嵌入做点积打分,再 softmax 变概率。为了演示,我们直接手写一行"读完上下文后的输出向量"(真实模型由几十层注意力算出来):
import numpy as np
vocab = {"[PAD]": 0, "[CLS]": 1, "[SEP]": 2, "<挖空>": 3,
"猫": 4, "吃": 5, "鱼": 6, "了": 7}
E = np.array([
[-1.00, -1.00, -1.00], # [PAD]
[-0.40, -0.40, -0.40], # [CLS]
[-0.40, -0.30, -0.35], # [SEP]
[ 0.00, 0.00, 0.00], # <挖空>
[ 0.20, 0.10, 0.05], # 猫
[ 1.00, 0.55, 0.28], # 吃
[ 0.10, 0.20, 0.50], # 鱼
[ 0.05, 0.10, 0.20], # 了
])
# 假装是"挖空"位置读完上下文后的输出向量(真实模型由前向传播算出)
ctx = np.array([2.00, 1.10, 0.56])
logits = ctx @ E.T # 对词表里每个词打相似分
p = np.exp(logits) / np.exp(logits).sum()
for w, prob in zip(vocab, p):
print(w, np.round(prob, 3))
你会看到:
[PAD] 0.001
[CLS] 0.01
[SEP] 0.012
<挖空> 0.045
猫 0.076
吃 0.705
鱼 0.09
了 0.061
| 代码 | 作用 |
|---|---|
E 手工矩阵 |
假装训练完的嵌入:语义相关的词向量方向靠得更近 |
ctx = np.array([2.00, 1.10, 0.56]) |
挖空位置的输出向量,本质是模型靠双向上下文"攒"出来的;这里手工顶替 |
ctx @ E.T |
老三样第 1 步,点积打分;分高的词越像答案 |
np.exp(logits) / ... |
softmax:分数变概率 |
| 输出里"吃" 0.705 | "猫""鱼""了"这几个词组成语境,最像的答案一目了然;训练到位的模型正是这样出答案的 |
预训练任务二:下一句预测(NSP)
另一件老牌预训练活是下一句预测(NSP,Next Sentence Prediction):把两句拼接成 [CLS] 甲 [SEP] 乙 [SEP],句对一半真一半假,让模型判断乙是否是甲的后句——教你"读联系"。后续研究发现收益有限,RoBERTa 等改进版干脆弃用;MLM 则活到了今天。
后训练:把语言学徒教成一技之长
预训练产出的是一个"读遍书房的学徒"——它懂通用的语言规律,但不专攻任何具体任务。拿去干正事还需要后训练(fine-tuning,微调),步骤通常三件事:
- 挑任务配数据:文本分类、问答、序列标注,需要几百到几万条人工标注样本
- 换头:把预训练时"预测词表里 3 万个词"的输出头拆掉,换成任务所需的小头(分类头只需输出 2 个数的连接层)
- 小步重训:沿用预训练权重初始化,给你很小的学习率轻轻训练几轮
预训练 vs 后训练的分工:
| 预训练 | 后训练(微调) | |
|---|---|---|
| 数据 | 海量无标注语料(几十 GB 起步) | 少量带标注的任务数据(几百~几万条) |
| 算力 | 大算力、数周 | 小算力、几十分钟到几小时 |
| 产出 | 通用"底座"(如 BERT 基座) | 某个具体任务的可用模型 |
最经典的微调姿势——拿 [CLS] 摘要向量接分类头:
import numpy as np
cls_out = np.array([1.0, -0.5]) # 假装 [CLS] 句首输出的"本句摘要"向量
W_head = np.array([[ 1.5, -1.0], # 分类头:摘要向量 → 正面/负面两类
[-1.2, 1.5]])
scores = cls_out @ W_head # 各打一个类别分
print(np.round(scores, 2))
p = np.exp(scores) / np.exp(scores).sum()
print(np.round(p, 3))
你会看到:
[ 2.1 -1.75]
[0.979 0.021]
| 代码 | 作用 |
|---|---|
cls_out |
[CLS] 位置的双维输出向量;真实模型里它是几十维的"句摘要" |
W_head |
微调出来的分类头:输入摘要向量,输出每个类别的分数 |
cls_out @ W_head |
点积打分:正面得 2.1 分、负面得 -1.75 分 |
np.round(p, 3) |
softmax:0.979 vs 0.021 → 模型非常有把握判成"正面" |
完整链路是:句子 → 编码器双向读懂 → [CLS] 汇总成摘要 → 分类头给判词。这就是 BERT 时代的"预训练 + 微调"范式;想亲手经历一次后训练,去 LLaMA-Factory。
记忆口诀:解码蒙前逐词放,编码双向满目望;挖空出答案靠 softmax,CLS 接头微调当手艺。
动手改
- 把挖空从"吃"换到"鱼"(改下标),对照输入侧输出的 id 变化
- 在 sentence 后再拼一句,练手
[SEP]分隔的两句话输入 - 把预测段的
ctx改成np.array([0.10, 0.20, 0.50])(与"鱼"完全同向),重跑看概率洗牌——验证"输出向量指向谁,谁的概率就大" - 反向想想:GPT 要是也想"填空",为什么不行?(提示:它读不到右边)
下一章:综合练习,把前六章组件串成迷你 Transformer 🐸