阶段三 · BERT 微调与意图识别

BERT 原理与预训练范式

一句话总结

BERT = Transformer Encoder + 两个巧妙的预训练任务(遮词填空 + 句对判断).
先在海量文本上预训练出"通用语言理解能力",再用少量标注数据微调到具体任务--这个范式让 NLP 从"每个任务从零训练"跨入了"站在巨人肩膀上"的时代.

前置回顾

上一篇我们理解了 Transformer 的核心机制:Self-Attention 让每个词直接看到所有其他词,多头注意力捕获多种关系,位置编码保留词序. BERT 就是取了 Transformer 的 Encoder 部分,加上独特的预训练策略,打造出了一个"通用语言理解引擎".

预训练 + 微调:范式转变

在 BERT 之前,NLP 任务的典型流程是:

旧范式(每个任务从零开始):
任务A的数据 → 训练模型A → 用模型A
任务B的数据 → 训练模型B → 用模型B
任务C的数据 → 训练模型C → 用模型C

每个模型从随机参数开始训练
需要大量标注数据才能达到好效果
模型之间不共享知识

BERT 范式(预训练 + 微调):
海量无标注文本 → 预训练 BERT(学通用语言知识)

┌─────────────┼─────────────┐
↓ ↓ ↓
少量任务A数据 少量任务B数据 少量任务C数据
→ 微调 → 微调 → 微调
→ 模型A → 模型B → 模型C

关键转变:语言的通用知识(语法,语义,常识)只需要学一次. 具体任务只需要在预训练好的基础上做"微调"--调整最后几层的参数,让模型适配特定任务.

预训练 = 大学四年通识教育. 学了数学,英语,计算机基础,不针对任何具体岗位.
微调 = 入职后的岗前培训. 用几周时间学习公司的业务系统和代码规范. 四年通识 + 几周培训 = 上手快,效果好.
相比之下,旧范式就像不上大学,每换一个岗位都从零学起.

BERT 的架构:只用 Encoder

BERT 的全称是 Bidirectional Encoder Representations from Transformers. 关键词是 Bidirectional(双向):

GPT(单向,左到右):
"管理员 禁言 了 用户"
←── 只能看左边的词

处理"用户"时,能看到"管理员 禁言 了",但看不到右边
适合生成任务(逐词生成,天然是从左到右)

BERT(双向):
"管理员 禁言 了 用户"
←→ 每个词同时看到左边和右边

处理"禁言"时,同时看到"管理员"(左)和"用户"(右)
适合理解任务(分类,阅读理解需要看完整上下文)

架构参数:

参数 BERT-base BERT-large
Transformer 层数 12 24
隐藏维度 768 1024
注意力头数 12 16
参数量 1.1 亿 3.4 亿
最大序列长度 512 512

对群聊管理场景,BERT-base 就够了. 消息短(通常 < 50 字),任务不复杂(分类),BERT-base 的 1.1 亿参数绰绰有余.

预训练任务一:MLM(遮词填空)

Masked Language Model(MLM)是 BERT 最核心的预训练任务. 思路极简:随机遮住输入中 15% 的词,让模型猜被遮住的是什么.

原始文本: "管理员禁言了发广告的用户"

MLM 处理后的输入:
"管理员 [MASK] 了 发广告 的 用户"

模型任务: 预测 [MASK] 位置的词 → "禁言"

为什么这能学到语言知识?
要正确预测 [MASK] = "禁言",模型必须理解:
- "管理员" 后面通常跟动作(语法知识)
- "了...用户" 暗示是对人执行的操作(搭配知识)
- "发广告" 是违规行为,对应的动作是"禁言"而非"表扬"(语义知识)

15% 的遮盖中,又做了细分处理(防止模型只学会处理 [MASK] 标记):

操作 比例 目的
替换为 [MASK] 80% 主要的训练信号
替换为随机词 10% 让模型学会"发现异常词"
保持不变 10% 让模型学会"确认正确的词"

MLM 的聪明之处

MLM 不需要任何人工标注. 训练数据就是普通文本--随机遮住一些词就自动生成了"输入-答案"对. 这意味着可以用海量的未标注文本(维基百科,新闻,书籍)进行训练,数据量几乎无限.

BERT 原始论文使用了 33 亿词的英文语料(英文维基 + BooksCorpus). 中文 BERT 使用了类似量级的中文语料.

预训练任务二:NSP(下一句预测)

Next Sentence Prediction(NSP)让模型判断两个句子是否是连续的:

正例(IsNext):
句子A: "管理员发现有人发广告"
句子B: "随后禁言了该用户"
标签: IsNext(B 确实是 A 的下一句)

负例(NotNext):
句子A: "管理员发现有人发广告"
句子B: "今天天气不错"
标签: NotNext(B 是随机拼凑的,和 A 无关)

NSP 让模型学习句子之间的逻辑关系. 输入格式用 [SEP] 分隔两个句子:

[CLS] 管理员 发现 有人 发 广告 [SEP] 随后 禁言 了 该 用户 [SEP]
↑ ↑ ↑
句子开头 句子分隔 句子结尾

[CLS] 位置的输出向量 → 过一个分类层 → 预测 IsNext / NotNext

NSP 的争议

后续研究(RoBERTa 论文)发现 NSP 任务对下游效果的帮助有限,甚至可能有害. RoBERTa 去掉了 NSP,只保留 MLM,效果反而更好. 不过理解 NSP 有助于理解 BERT 的输入格式(为什么有 [CLS] 和 [SEP]).

BERT 的输入表示

BERT 的输入由三个 Embedding 相加而成:

输入文本: "[CLS] 管理员 禁言 了 用户 [SEP]"

Token Embedding: [v_cls] [v_管理员] [v_禁言] [v_了] [v_用户] [v_sep]
+ + + + + +
Position Embedding: [p_0] [p_1] [p_2] [p_3] [p_4] [p_5]
+ + + + + +
Segment Embedding: [s_A] [s_A] [s_A] [s_A] [s_A] [s_A]
‖ ‖ ‖ ‖ ‖ ‖
最终输入向量: [e_0] [e_1] [e_2] [e_3] [e_4] [e_5]
Embedding 作用 维度
Token Embedding 每个 token 的语义向量 词表大小 × 768
Position Embedding 编码位置信息(第几个词) 512 × 768
Segment Embedding 区分第一句 / 第二句(用于 NSP) 2 × 768

对单句分类任务(如对骂检测),所有 token 的 Segment Embedding 都是 s_A. Segment Embedding 主要在句对任务中发挥作用.

中文 BERT 变体

原始 BERT 的中文处理比较粗糙--按单字分词("管理员" → "管""理""员"). 后续的中文优化版本解决了这个问题:

chinese-bert-wwm-ext

Whole Word Masking(WWM):遮盖时以完整词为单位,而非单字.

原始 BERT 的 MLM:
"管理员 禁言 了 用户" → "管 [MASK] 员 禁言 了 用户"
模型只需猜一个字 "理",太简单,学不到深层语义

WWM 的 MLM:
"管理员 禁言 了 用户" → "[MASK][MASK][MASK] 禁言 了 用户"
整个 "管理员" 被遮住,模型必须从上下文推断这里是什么角色
→ 迫使模型学习更深层的语义理解

ext 表示使用了更大的训练语料(Extended),效果进一步提升.

chinese-roberta-wwm-ext

在 chinese-bert-wwm-ext 基础上,引入 RoBERTa 的改进:

改进点 BERT RoBERTa
NSP 任务 去掉(实验证明无用)
训练数据量 标准量 更多数据,更多 epoch
Mask 策略 静态(训练前固定) 动态(每个 epoch 重新随机)
batch size 256 更大(8K),训练更稳定

群聊场景推荐

对群聊管理任务,推荐 hfl/chinese-roberta-wwm-ext. 它在中文理解任务上表现最稳定,社区使用广泛,HuggingFace 上可以直接下载. 如果模型体积敏感,可以用 hfl/chinese-roberta-wwm-ext-small(6 层,参数量减半).

为什么不从零训练

一个直觉上的疑问:为什么不拿自己的群聊数据从头训练一个模型?

从零训练一个 BERT-base:
- 需要 33 亿+ 词的语料
- 需要 16 块 V100 GPU 训练 4 天
- 约 $5000-10000 计算成本
- 训练出的模型只有"语言知识",还没有"任务知识"

用预训练 BERT 微调:
- 需要 1000-5000 条标注数据
- 需要 1 块 GPU 训练 30 分钟
- 约 $1-5 计算成本
- 从"通用语言知识"出发,快速学会"对骂检测"

预训练模型已经理解了中文的语法,词义,搭配关系,常见表达模式. 微调只需要教它一件事:"在群聊场景中,什么算对骂,什么算正常". 这就像让一个精通中文的人学一个新的分类标准--比教一个完全不懂中文的人快得多.

从零训练 = 自己写一个 HTTP 框架再开发业务. 能做,但耗时巨大,且未必比成熟框架好.
微调预训练模型 = 用 Gin/Echo 框架写业务逻辑. 框架处理了路由,中间件,序列化等通用问题,你只需要写业务 handler.

微调的工作原理

微调(Fine-tuning)在预训练 BERT 的基础上做两件事:

  1. 加一个任务头:在 [CLS] 输出上接一个分类层
  2. 用标注数据训练:整个模型(包括 BERT 本身的参数)都参与训练
微调结构(以二分类为例):

输入: [CLS] 你 脑子 有 问题 吧 [SEP]

┌──────────────────────────┐
│ BERT 12 层 │ ← 预训练好的参数,微调时小幅调整
│ (Transformer Encoder) │
└──────────────────────────┘

取 [CLS] 位置的输出: 768 维向量

┌──────────────────────────┐
│ 分类头: Linear(768, 2) │ ← 新加的层,随机初始化
│ → softmax │
└──────────────────────────┘

输出: [对骂: 0.92, 正常: 0.08]

训练时的学习率策略很关键:

学习率 原因
分类头(新加的) 较大(如 1e-3) 从随机参数开始,需要大步更新
BERT 高层(第 9-12 层) 中等(如 2e-5) 需要适配任务,但不能偏离太远
BERT 低层(第 1-4 层) 很小或冻结 低层编码通用语法知识,不需要大改

微调的常见错误

学习率太大(如 1e-3 应用到整个 BERT)会"灾难性遗忘"--预训练学到的语言知识被冲掉,模型退化成在小数据上过拟合的模型. 典型症状:训练集 accuracy 99%,测试集只有 60%.

经验值:BERT 部分的学习率用 2e-5 到 5e-5,这是社区反复验证过的安全区间.

预训练与微调的参数对比

直观看看两个阶段的规模差异:

维度 预训练 微调
数据量 33 亿+ 词 1000-10000 条
标注 不需要(自监督) 需要人工标注
训练时间 数天(多卡) 30 分钟-2 小时(单卡)
学习率 1e-4 2e-5
目标 通用语言理解 特定任务
谁做 Google / 哈工大等团队 我们(业务方)

预训练是别人做好的"基础设施",微调才是我们真正需要做的工作.

从 BERT 到下一步

本篇搞清楚了 BERT 的原理和预训练范式. 下一篇进入实战:用 HuggingFace Transformers 库加载预训练的中文 BERT,在群聊数据上完成一次完整的微调训练.

知识链路:

第 10 篇: Transformer 架构(Self-Attention, 并行)

第 11 篇: BERT = Transformer Encoder + MLM/NSP 预训练 (本篇)

第 12 篇: HuggingFace 实战(加载模型 → 准备数据 → 微调训练)

第 13 篇: 意图识别(从分类扩展到指令解析)

第 14 篇: 模型蒸馏与轻量化(部署前的优化)

快速回顾

  • 预训练 + 微调:通用知识学一次,具体任务只需少量数据微调. NLP 从"每次从零开始"变成"站在巨人肩膀上"
  • MLM(遮词填空):随机遮住 15% 的词让模型预测,不需要人工标注,可以用海量文本训练
  • NSP(下一句预测):判断两句话是否连续,学习句间关系. 后续研究证明可以去掉
  • 双向 Encoder:BERT 每个词同时看到左右上下文,适合分类等理解任务
  • 中文推荐:hfl/chinese-roberta-wwm-ext,WWM 按完整词遮盖,RoBERTa 改进训练策略
  • 微调学习率:BERT 部分用 2e-5 到 5e-5,太大会"灾难性遗忘"

动手练习

  1. 加载预训练模型:用 HuggingFace AutoModel.from_pretrained("hfl/chinese-roberta-wwm-ext") 下载模型,打印模型结构,数一下参数量
  2. MLM 实验:用 pipeline("fill-mask", model="hfl/chinese-roberta-wwm-ext") 测试遮词填空. 输入 "管理员[MASK]了发广告的用户",看模型预测的 top-5 候选词是否合理
  3. 对比中文变体:分别用 bert-base-chinesechinese-roberta-wwm-ext 做相同的 MLM 实验,对比预测结果的质量
  4. 参数量计算:根据 config.json 中的架构参数(vocab_size, hidden_size, num_layers, intermediate_size),手动计算 Embedding 层 + 12 层 Transformer + 分类头的参数量,和 model.num_parameters() 的结果对比验证