1. 为什么踩完 BiLSTM 还得换 BERT
上一篇文章里,我用 BiLSTM+CRF 把中文命名实体识别跑通了一个基础版本,效果其实还行,F1 能到 82 左右。但很快你就会发现一个扎心的事:模型对“一词多义”基本无能为力。比如“苹果”在“苹果发布了新手机”和“他削了个苹果”里,BiLSTM 学到的向量几乎一模一样,这是静态词向量的天花板。
所以这一篇,我直接上了 BERT+CRF。核心变化就一句话:把 BiLSTM 换掉,让 BERT 来负责上下文语义编码,CRF 继续负责标签之间的转移约束。这套组合到今天依然是中文 NER 任务里性价比比较高的方案,BERT 输出的向量直接接 CRF 层,不需要中间再插 BiLSTM 或别的结构,简单、稳定、效果好。
适合看这篇文章的人,我觉得有两类:一类是你已经跑通过 BiLSTM+CRF,想升级一下;另一类是你刚接触 NER,想直接从 BERT 时代起步,那我建议你先把 CRF 的基本原理搞明白,再看本文的实操部分会顺畅很多。
我会按这个顺序讲:先帮大家把 BERT+CRF 的方案选型逻辑捋清楚,重点说说为什么中间层能省就省;然后给出完整的训练流程,包括数据标注、标签编码、模型结构、训练参数和推理解码;最后把自己跑实验时踩过的几个坑和排查思路直接列出来,省得你再去翻一个星期的 issue。
2. BERT 到底给 CRF 带来了什么,中间层为什么可以去掉
2.1 方案选型的核心逻辑
先说清楚 BERT+CRF 在整个 NER 方案里的定位。现在的序列标注任务通常有两条主流路线:
| 方案 | 编码方式 | 解码方式 | 优点 | 缺点 |
|---|---|---|---|---|
| BiLSTM+CRF | 静态词向量 + 双向 LSTM | CRF 约束标签转移 | 训练快、显存占用小、部署成本低 | 对一词多义不敏感,长距离依赖有限 |
| BERT+CRF | BERT 动态上下文向量 | CRF 约束标签转移 | 语义理解强、效果上限高 | 训练慢、显存占用大、需要预训练模型 |
| BERT+BiLSTM+CRF | BERT + BiLSTM 双编码 | CRF 约束标签转移 | 理论上特征更强 | 参数多、提升有限、训练更慢 |
我自己两个方案都跑过,先说结论:BERT+CRF 大多数场景下已经够用,没必要画蛇添足再加 BiLSTM。
为什么可以省掉 BiLSTM?因为 BiLSTM 在原来的结构里承担的是“从上下文中提取序列特征”的工作,但 BERT 本身做的就是这件事,而且做得更深。BERT 的每一层 Transformer Block 都在做双向的上下文交互,它对序列的建模能力完全覆盖了 BiLSTM 能学到的特征。你再在 BERT 后面接一个 BiLSTM,相当于让 BERT 把语义理解了,然后 BiLSTM 再从头学一遍,大概率只是加了一堆参数,还有些微的提升,但代价是训练时间接近翻倍。
2.2 CRF 在这套结构里到底在做什么
很多人对 CRF 有误解,觉得 CRF 是用来“分类”的。其实 BERT 输出的每个 token 特征送入一个全连接层(Softmax)之后,每个位置已经能产生各个实体标签的概率了,这一步叫发射分数(Emission Score)。
但单独看每个位置的 Softmax,存在一个常见问题:模型的决策之间缺少关联。比如“张三在北京工作”,模型在“北”这个字上有较大概率预测出 B-LOC,在“京”上预测出 I-LOC,但如果没有 CRF,就可能出现“张三”第一个字预测成 I-PER(因为 I 标签不能作为开始标签)、“北京”中间夹了一个 I-ORG 之类的明显逻辑错误。
CRF 解决的就是这个约束问题。它会在所有可能的标签序列里,找一条全局最优路径,同时满足两个条件:
- BERT 在该位置给出的发射分数要高
- 相邻标签之间的转移分数要合理,比如 B-LOC 后面应该接 I-LOC,不能接 I-PER,O 后面不能接 I 类标签(除非是 B 开头)
训练阶段,CRF 通过极大似然估计来学习这套转移规则。推理阶段,用Viterbi 算法在全部标签序列中解码出概率最高的那条,保证整条序列的标签逻辑连贯。
我打一个生活化的比方:BERT 有点像各科老师分别改了每道题,给出了每道题的得分;CRF 则是年级组长做最终审核,他能发现“这道题选伊拉克,下道题却按日本语法扣分”这种离谱搭配,然后把整张卷子的逻辑统一起来。
2.3 为什么中间加一层全连接(Dense)是有必要的
BERT 输出的维度是固定的,中文 BERT 一般是 768 维,而标签数量通常只有 7 到 20 个。从 768 维直接映射到 logits 其实很简单,用一个全连接层就够了,这一步不是可选而是必须——因为你需要一个可训练的分类头把语义向量转成标签得分。
我习惯把这个全连接层叫做“分类头”,它不含隐藏层,就一层线性变换,参数量很小。有些人喜欢在 BERT 后面加一个 hidden=256 的隐藏层再接分类头,我试过,在数据量不大的中文 NER 任务里基本没差别,反而更容易过拟合。直接用 Dense(num_labels) 即可,保持简单。
3. 数据准备与标签编码:决定模型上限的隐形环节
3.1 标注格式与数据集划分
NER 训练数据最常用的格式是BIO或BIOES。我习惯用 BIO,简单直接。每个 token(中文就是一个字)一行,由“字 + 空格 + 标签”组成,句子之间用空行分隔。
以“张三在北京工作”为例:
张 B-PER 三 I-PER 在 O 北 B-LOC 京 I-LOC 工 O 作 O如果你有现成的数据,比如爬下来的文本或者标注平台导出的数据,第一件事永远是清洗。我踩过最大的坑就是:一句话中间有空格、有全角标点混着半角标点、有英文单词被拆成多个 token 但标签没对齐。这些都会让模型在训练时学到一个错误的映射,而且不容易察觉。
数据集划分上,我一般按8:1:1分训练集、验证集、测试集。千万别用随机洗牌后直接切分,一定要保证同一句话不会同时出现在训练集和测试集中。数据量小的话,建议直接用 sklearn 的train_test_split或者KFold,但要在句子级别做索引切分,而不是按 token 切分。
3.2 标签表与 id 映射
标签需要转成 id,才能喂给模型。以 BIO 制、三类实体(PER/LOC/ORG)为例,标签表是:
O B-PER I-PER B-LOC I-LOC B-ORG I-ORG一共 7 个标签。如果你后续要扩展到时间、日期、产品名等实体,直接把新标签加进去重新映射 id 即可,但要保证全项目统一,不会出现“同一个标签在不同脚本里 id 不一致”这种经典事故。
我建议把标签表单独存成一个labels.txt文件,运行时读取,而不是散落在训练脚本里。这样换数据集、换机器、调整标签集合时,都不会出现一脸懵的情况。
3.3 token 对齐:BERT 分词器和中文标注的难题
中文 NER 用 BERT 时,最麻烦的就是 token 对齐。
因为 BERT 的分词器会对中文按字切分,但遇到英文单词、数字、标点,可能会把一个 token 拆成多个子 token(subword)。比如英文单词“unhappiness”可能被拆成“un”、“happiness”,或者“100”被拆成“10”、“0”。这时如果你直接给每个 token 打标签,标签数量就和 BERT 的输出维度对不上了。
解决办法分两种:
- 按字标注:如果语料里几乎全是中文,建议按字标注并强制 BERT 切分时每个字独立成 token。中文 BERT 的分词器默认对中文字符是一个字一个 token,只要语料里没有英文单词,基本不会出对齐问题。
- 按词标注且保留 subword 映射:对英文和数字,给第一个 subword 标真实标签,其余 subword 标为
X并在损失计算时忽略该位置。
我直接在数据预处理阶段把所有英文和数字都规范化,数字统一转成[NUM],英文单词统一转成小写。这样词表不会被大量低频词汇污染,训练稳定性也更高。这一招对中文 NER 提升挺明显的,尤其是“5G”“iPhone15”这种混合词出现比较多的场景。
3.4 dataset 构建的踩坑点
构建 Dataset 时最容易踩的坑是:
- attention_mask 没算对,pad 位置被当成了有效 token 参与计算
- token_type_ids 没加(虽然单句 NER 不需要,但 BERT 模型一般会要求输入)
- label 没有跟着 input_ids 一起 pad 成相同长度
- 训练时忘记在计算 loss 时屏蔽 pad 位置
我统一写成一句话:input_ids、attention_mask、token_type_ids、labels 四个数组必须在同一个 padding 规则下对齐。
4. 模型实现与训练参数:代码跑通到效果调优的全过程
4.1 模型结构怎么写
我直接用 Transformers 库加载预训练模型,然后自定义一个类别继承BertPreTrainedModel,也可以不用继承、直接定义一个nn.Module,代码更自由一点。
最精简的 BERT+CRF 模型核心代码如下:
import torch import torch.nn as nn from transformers import BertModel from torchcrf import CRF class BertCrfForNer(nn.Module): def __init__(self, bert_dir, num_labels): super(BertCrfForNer, self).__init__() self.num_labels = num_labels self.bert = BertModel.from_pretrained(bert_dir) self.dropout = nn.Dropout(0.1) self.classifier = nn.Linear( self.bert.config.hidden_size, num_labels ) self.crf = CRF(num_labels, batch_first=True) def forward(self, input_ids, attention_mask, labels=None): outputs = self.bert( input_ids=input_ids, attention_mask=attention_mask ) seq_output = outputs[0] # [batch, seq_len, hidden] seq_output = self.dropout(seq_output) emissions = self.classifier(seq_output) # [batch, seq_len, num_labels] if labels is not None: loss = -self.crf(emissions, labels, mask=attention_mask.bool()) return loss else: decode = self.crf.decode(emissions, mask=attention_mask.bool()) return decode这个实现里,最重要的一行是-self.crf(emissions, labels, mask=attention_mask.bool())。torchcrf返回的是对数似然值,我们要做梯度下降,所以取负号变成损失。这里的mask必须传进去,否则 CRF 会把 pad 位置也当作有效位置参与计算,整个标签序列的分数就乱掉了。
4.2 训练参数的起点与调优思路
训练参数我推荐从这一组起步,跑通之后再微调:
| 参数 | 建议值 | 说明 |
|---|---|---|
| batch_size | 16 或 32 | 显存不够就降到 8 |
| max_seq_len | 128 | 中文短文本任务足够 |
| learning_rate | 3e-5 | BERT 层常用范围 |
| optimizer | AdamW | 必须带 weight_decay |
| scheduler | linear decay | 训练后期更平滑 |
| epochs | 5 到 10 | 小数据集 5 轮基本收敛 |
| warmup_ratio | 0.1 | 前 10% 步数线性升温 |
BERT 的预训练参数已经很成熟,学习率不能太高,1e-4以上通常会震荡甚至发散。我一般设置成 3e-5,小数据集或任务简单时甚至用 2e-5,效果更稳定。
weight_decay我习惯设成 0.01,只对非 layer norm 和 bias 参数生效。写法上是把参数分成两组:一组用 weight decay,一组不用,然后传给 AdamW。你也可以直接在AdamW的构造函数里设置weight_decay=0.01,图省事也行,但效果略糙。
4.3 完整训练流程拆解
整个训练流程拆解下来其实只有四段:数据读取、模型加载、训练循环、验证循环。
数据读取阶段,我建议直接用 Datasets 库,加载出来是字典结构,容易操作。预处理函数里做 tokenize 和标签对齐,然后分 batch,交给 DataLoader。
模型加载阶段有个细节要注意:加载预训练模型时要指定return_dict=True,否则 outputs[0] 的语义会变,搞不清是 logits 还是 last_hidden_state。我一直用BertModel.from_pretrained(bert_dir)的默认设置,输出的outputs[0]就是序列向量。
训练循环比较常规,但有几个节奏点要卡住:
- 每个 epoch 结束后用验证集算一次 F1
- 保存模型时不要只存最终 epoch,要按 F1 最优的模型保存
- 训练步数较多时,用
torch.cuda.amp.autocast()做混合精度训练,显存降 30% 到 50%
验证循环里有一个特别容易写错的地方:验证集不需要算 loss,但要跑 CRF 解码,得到每个位置的预测标签。然后逐句和真实标签对比,统计精确率、召回率和 F1。
我写过一个比较实用的评估函数,核心逻辑是:遍历每个 batch,拿到 decode 结果,和 labels 逐位置比较,但一定要把 pad 位置过滤掉,统计时只算 attention_mask 为 True 的位置。
4.4 训练结果与观察指标
我在某高校自己整理的模拟数据集上训练,大概 1 万条标注句子、三类实体,BERT 用中文 base 版。训练约 6 个 epoch,最终测试集 F1 在 89 到 91 之间浮动,和之前 BiLSTM+CRF 的 82 相比,提升是实打实的。
但我建议你先别急着追求指标,第一优先级是确认 loss 曲线。loss 应该随着训练平稳下降,如果出现震荡,优先排查学习率是否过高、数据里是否存在标签错乱。第二个要关注的是验证集的 F1 是否在 epoch 中后期开始下降,如果是,大概率过拟合了,早停就好。
5. 推理部署与解码细节:从训练好的模型到实际能用
5.1 模型保存与加载标准姿势
训练完模型,推荐保存两种东西:模型权重和分词器配置。代码写法如下:
torch.save(model.state_dict(), "bert_crf_ner.pth") model.config.save_pretrained("model_config")加载时需要注意:因为你自定义了一个BertCrfForNer类,加载权重时逻辑是:
model = BertCrfForNer(bert_dir="bert-base-chinese", num_labels=7) model.load_state_dict(torch.load("bert_crf_ner.pth"))这里有一个容易踩的坑:load_state_dict的默认 strict 模式要求预训练权重和当前模型完全匹配,如果你保存时用的是“BERT 后加分类头后 CRF”的完整模型,加载就用完整模型类;如果你保存的是分类头的权重,那么加载时得先手动初始化 BERT 和 CRF,再 load 分类头的 state_dict。我建议直接保存完整模型,省时省力。
5.2 推理阶段标签到实体的恢复
模型 decode 出来是标签序列,比如:
["O", "B-PER", "I-PER", "O", "B-LOC", "I-LOC", "O"]你需要把它还原成带边界的实体列表。我写了一个规则简单的小函数,遇到B-类标签就开始一个实体,遇到I-类标签且与当前实体的类型一致就续接,遇到O或B-新类型就关闭上一个实体并开启新实体。用这个规则可以把“张三/北京”这种结果正确还原成两个实体。
推理阶段不要忘记把 pad 位置的预测过滤掉,只取attention_mask==1的部分。这句话我重复了三次,因为它真的很重要,少了它你会在开发环境一切正常、上线后发现实体边界突然错乱。
5.3 部署时的显存与速度优化
BERT 模型参数较大,部署到 CPU 上推理会很慢。如果你的场景对延迟敏感,考虑这几个优化:
- 转成 ONNX 或使用 TensorRT,速度提升明显
- 对小规模并发场景,用单卡批量推理即可,无需上 GPU 集群
- 如果实体类型少、句子短,可以裁剪 max_seq_len,缩短计算链条
我个人在模拟项目里,用单卡 GPU 跑 100 条短句推理,耗时在 0.2 秒左右(不含加载模型)。把句子长度裁剪到 64 后,速度更快,但要注意别把实体的关键上下文裁掉。
6. 常见问题与排查技巧实录
6.1 CRF loss 为 NaN
这个问题几乎人人都会遇到一次。排查顺序是:
- 检查 learning rate,如果大于 5e-5,先降下来
- 检查标签 id 是否从 0 连续编号,CRF 实现要求标签 id 是连续的整数
- 检查是否存在空标签序列(某个 batch 里全是 pad 位置)
- 检查 attention_mask 是否全 0(句子长度上限内可能被裁掉)
我在模拟实验里遇到 NaN,最后定位到是标签表里有一个 id 跳号,CRF 的转移矩阵初始化时把不存在的标签也算进去了,导致梯度爆炸。把标签表按顺序重新编号后,问题立刻消失。
6.2 训练时实体边界混乱但训练集标注无误
这种情况多半是 max_seq_len 设置太短,关键上下文被截断了。比如“张三在北京大学读书”,“北京大学”的“大”和“学”可能在序列末尾被截掉一半,导致模型学不到完整实体。建议设置 max_seq_len 时,先统计语料中最长句子的长度,再加 10 到 20 个 token 的余量。
另外,如果你把所有句子都强行 pad 到同一个长度,CRF 在 pad 位置上的转移约束会浪费大量计算,而且容易让模型学到“pad 后面永远是 O”这种无意义规律。我一般按 batch 内部的动态 padding 处理,这样每个 batch 的长度以本 batch 最长句子为准,效率高很多。
6.3 BERT 层学习率与分类头学习率不一致怎么办
有时候你希望分类头和 CRF 层学得快一点,BERT 层学得慢一点。这可以通过参数分组实现:
optimizer_grouped_parameters = [ {"params": [p for n, p in model.named_parameters() if "bert" in n], "lr": 3e-5}, {"params": [p for n, p in model.named_parameters() if "bert" not in n], "lr": 1e-4}, ]这种分组方式在实际任务中效果明显。因为分类头和 CRF 层属于随机初始化的参数,需要相对大的学习率来快速收敛;BERT 预训练权重只需要微调,学习率太大反而破坏语义表示。
6.4 预处理标签错位问题
如果训练后期 F1 一直上不去,先怀疑数据,再怀疑模型。我之前遇到过一个场景:一句话里有两个连续英文单词,被 BERT 分词器切开后标签没对齐,模型在英文位置疯狂出错。解决办法是预处理阶段把所有英文和数字统一映射成[NUM]或[UNK],这样分词器不会产生额外的 subword,对齐问题直接消失。
6.5 单条推理速度太慢怎么办
如果你需要在 CPU 或者低配 GPU 上提供在线服务,建议一次性加载模型,不要每条请求都重新加载。推理时可以设置torch.no_grad(),并关闭梯度计算,这一步能节省很多无谓的内存开销。另外,用torch.jit.trace或 ONNX 导出后,推理速度可能提升 2 到 4 倍,我试过一次,很值。
7. 我的经验总结
BERT+CRF 跑通之后,我再回头看 BiLSTM+CRF,最大的感触就是:如果数据量允许,直接用 BERT 做序列特征提取,反而省去了调 LSTM 各种超参的精力,把时间花在数据清洗和标签校验上更划算。
几个核心经验再罗嗦一遍:
- CRF 不要去掉。虽然 BERT 很强,但 CRF 的全局标签约束在 NER 上依然有效,尤其对于长实体和跨句实体,有没有 CRF,F1 能差 2 到 3 个点。
- 数据质量决定了模型上限。哪怕你的 BERT 用的是大版本,标签错太多也白搭。我在数据清洗上花的时间比训练多一倍。
- 动态 padding + mask 别偷懒。这三行代码看似简单,省了就会炸。
- 保留中间检查点。我每过两个 epoch 保存一次模型和配置,出了问题时可以回退到较早的稳定状态,非常实用。
最后分享一个小技巧:如果你打算在某个垂直领域(比如医疗、法律、金融)做 NER,建议先尝试加载一个领域相关的 BERT 模型,或者用领域语料继续预训练 BERT 一小段时间,再去做序列标注,效果会比直接用通用中文 BERT 高出一截。这个经验在多个模拟项目里都验证过,值得一试。