BERT+CRF中文命名实体识别实战:从模型搭建到训练调优全流程

发布时间:2026/10/14 12:26:15
BERT+CRF中文命名实体识别实战:从模型搭建到训练调优全流程 1. 为什么踩完 BiLSTM 还得换 BERT上一篇文章里我用 BiLSTMCRF 把中文命名实体识别跑通了一个基础版本效果其实还行F1 能到 82 左右。但很快你就会发现一个扎心的事模型对“一词多义”基本无能为力。比如“苹果”在“苹果发布了新手机”和“他削了个苹果”里BiLSTM 学到的向量几乎一模一样这是静态词向量的天花板。所以这一篇我直接上了 BERTCRF。核心变化就一句话把 BiLSTM 换掉让 BERT 来负责上下文语义编码CRF 继续负责标签之间的转移约束。这套组合到今天依然是中文 NER 任务里性价比比较高的方案BERT 输出的向量直接接 CRF 层不需要中间再插 BiLSTM 或别的结构简单、稳定、效果好。适合看这篇文章的人我觉得有两类一类是你已经跑通过 BiLSTMCRF想升级一下另一类是你刚接触 NER想直接从 BERT 时代起步那我建议你先把 CRF 的基本原理搞明白再看本文的实操部分会顺畅很多。我会按这个顺序讲先帮大家把 BERTCRF 的方案选型逻辑捋清楚重点说说为什么中间层能省就省然后给出完整的训练流程包括数据标注、标签编码、模型结构、训练参数和推理解码最后把自己跑实验时踩过的几个坑和排查思路直接列出来省得你再去翻一个星期的 issue。2. BERT 到底给 CRF 带来了什么中间层为什么可以去掉2.1 方案选型的核心逻辑先说清楚 BERTCRF 在整个 NER 方案里的定位。现在的序列标注任务通常有两条主流路线方案编码方式解码方式优点缺点BiLSTMCRF静态词向量 双向 LSTMCRF 约束标签转移训练快、显存占用小、部署成本低对一词多义不敏感长距离依赖有限BERTCRFBERT 动态上下文向量CRF 约束标签转移语义理解强、效果上限高训练慢、显存占用大、需要预训练模型BERTBiLSTMCRFBERT BiLSTM 双编码CRF 约束标签转移理论上特征更强参数多、提升有限、训练更慢我自己两个方案都跑过先说结论BERTCRF 大多数场景下已经够用没必要画蛇添足再加 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-PERO 后面不能接 I 类标签除非是 B 开头训练阶段CRF 通过极大似然估计来学习这套转移规则。推理阶段用Viterbi 算法在全部标签序列中解码出概率最高的那条保证整条序列的标签逻辑连贯。我打一个生活化的比方BERT 有点像各科老师分别改了每道题给出了每道题的得分CRF 则是年级组长做最终审核他能发现“这道题选伊拉克下道题却按日本语法扣分”这种离谱搭配然后把整张卷子的逻辑统一起来。2.3 为什么中间加一层全连接Dense是有必要的BERT 输出的维度是固定的中文 BERT 一般是 768 维而标签数量通常只有 7 到 20 个。从 768 维直接映射到 logits 其实很简单用一个全连接层就够了这一步不是可选而是必须——因为你需要一个可训练的分类头把语义向量转成标签得分。我习惯把这个全连接层叫做“分类头”它不含隐藏层就一层线性变换参数量很小。有些人喜欢在 BERT 后面加一个 hidden256 的隐藏层再接分类头我试过在数据量不大的中文 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 拆成多个子 tokensubword。比如英文单词“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代码更自由一点。最精简的 BERTCRF 模型核心代码如下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_firstTrue) def forward(self, input_ids, attention_mask, labelsNone): outputs self.bert( input_idsinput_ids, attention_maskattention_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, maskattention_mask.bool()) return loss else: decode self.crf.decode(emissions, maskattention_mask.bool()) return decode这个实现里最重要的一行是-self.crf(emissions, labels, maskattention_mask.bool())。torchcrf返回的是对数似然值我们要做梯度下降所以取负号变成损失。这里的mask必须传进去否则 CRF 会把 pad 位置也当作有效位置参与计算整个标签序列的分数就乱掉了。4.2 训练参数的起点与调优思路训练参数我推荐从这一组起步跑通之后再微调参数建议值说明batch_size16 或 32显存不够就降到 8max_seq_len128中文短文本任务足够learning_rate3e-5BERT 层常用范围optimizerAdamW必须带 weight_decayschedulerlinear decay训练后期更平滑epochs5 到 10小数据集 5 轮基本收敛warmup_ratio0.1前 10% 步数线性升温BERT 的预训练参数已经很成熟学习率不能太高1e-4以上通常会震荡甚至发散。我一般设置成 3e-5小数据集或任务简单时甚至用 2e-5效果更稳定。weight_decay我习惯设成 0.01只对非 layer norm 和 bias 参数生效。写法上是把参数分成两组一组用 weight decay一组不用然后传给 AdamW。你也可以直接在AdamW的构造函数里设置weight_decay0.01图省事也行但效果略糙。4.3 完整训练流程拆解整个训练流程拆解下来其实只有四段数据读取、模型加载、训练循环、验证循环。数据读取阶段我建议直接用 Datasets 库加载出来是字典结构容易操作。预处理函数里做 tokenize 和标签对齐然后分 batch交给 DataLoader。模型加载阶段有个细节要注意加载预训练模型时要指定return_dictTrue否则 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 之间浮动和之前 BiLSTMCRF 的 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_dirbert-base-chinese, num_labels7) 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_mask1的部分。这句话我重复了三次因为它真的很重要少了它你会在开发环境一切正常、上线后发现实体边界突然错乱。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. 我的经验总结BERTCRF 跑通之后我再回头看 BiLSTMCRF最大的感触就是如果数据量允许直接用 BERT 做序列特征提取反而省去了调 LSTM 各种超参的精力把时间花在数据清洗和标签校验上更划算。几个核心经验再罗嗦一遍CRF 不要去掉。虽然 BERT 很强但 CRF 的全局标签约束在 NER 上依然有效尤其对于长实体和跨句实体有没有 CRFF1 能差 2 到 3 个点。数据质量决定了模型上限。哪怕你的 BERT 用的是大版本标签错太多也白搭。我在数据清洗上花的时间比训练多一倍。动态 padding mask 别偷懒。这三行代码看似简单省了就会炸。保留中间检查点。我每过两个 epoch 保存一次模型和配置出了问题时可以回退到较早的稳定状态非常实用。最后分享一个小技巧如果你打算在某个垂直领域比如医疗、法律、金融做 NER建议先尝试加载一个领域相关的 BERT 模型或者用领域语料继续预训练 BERT 一小段时间再去做序列标注效果会比直接用通用中文 BERT 高出一截。这个经验在多个模拟项目里都验证过值得一试。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询