🐸 青蛙编程网
首页 / AI / Transformer 原理 / 6. BERT:看懂句子的编码器

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,微调),步骤通常三件事:

  1. 挑任务配数据:文本分类、问答、序列标注,需要几百到几万条人工标注样本
  2. 换头:把预训练时"预测词表里 3 万个词"的输出头拆掉,换成任务所需的小头(分类头只需输出 2 个数的连接层)
  3. 小步重训:沿用预训练权重初始化,给你很小的学习率轻轻训练几轮

预训练 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 接头微调当手艺。

动手改

  1. 把挖空从"吃"换到"鱼"(改下标),对照输入侧输出的 id 变化
  2. 在 sentence 后再拼一句,练手 [SEP] 分隔的两句话输入
  3. 把预测段的 ctx 改成 np.array([0.10, 0.20, 0.50])(与"鱼"完全同向),重跑看概率洗牌——验证"输出向量指向谁,谁的概率就大"
  4. 反向想想:GPT 要是也想"填空",为什么不行?(提示:它读不到右边)

下一章:综合练习,把前六章组件串成迷你 Transformer 🐸

📥 下载本章代码

ZIP 内含本章全部代码文件与说明,解压即用,与教程内容同步生成。