资讯动态

BERT+BiLSTM+CRF中文命名实体识别实战:源码解析与调优

发布时间:2026/10/9 13:44:27 来源:尧图企业网站定制
简介面向计算机专业学生课程设计或期末大作业的Python项目基于BERT、BiLSTM与CRF实现中文命名实体识别NER覆盖从数据处理到模型调用的一套完整代码。项目源自作者大三期末作业经导师指导并获得99分评审分代码完整可运行适合零基础或初次接触NER的学习者进行项目实战。压缩包共15个文件以9个Python源码为主辅以4个文本说明、1个Markdown文档和1个gitignore配置整体仅406KB。内容包含torch_ner核心模块、data数据目录、source源码目录、requirements.txt依赖说明与README项目说明目录结构清晰同时配有项目整体说明和运行依赖清单便于快速搭建环境、理解数据流转与模型调用方式。目前已有90人学习下载可直接作为期末大作业、课程设计或入门级NLP项目的参考工程也适合在已有代码基础上做二次拓展练习。1. 把这三个模型拼在一起到底在解决什么问题中文命名实体识别NER这几年基本绕不开 BERTBILSTMCRF 这个组合。你可以把它理解成一条流水线BERT 负责把每个字变成有语境的向量BILSTM 负责看上下文重新梳理一遍CRF 负责最终给每个字贴标签并且保证标签顺序合法。单独用 BERT 也能做 NER但边界会乱单独用 BILSTMCRF 也能做但对一词多义、领域词汇的泛化不够。三者串起来是效果和资源消耗之间比较稳的一个折中方案。这个标题里的 python 源码 项目说明 模型意味着你拿到的不是论文讲解而是能直接跑的工程包。适合的人群有两类一是要做毕设或课程设计的学生二是公司里需要快速给文本抽实体但不想从零写模型的工程师。前者关心怎么跑通、怎么改数据集后者更关心训练一次多久、显存占用多少、边界准不准。这篇笔记我按两条线展开先讲清楚每一步为什么要这么做再给出可复现的训练命令、参数和踩坑记录。2. 模型结构拆解BERT的向量、BILSTM的上下文、CRF的约束为什么要三个串起来很多项目里把这套模型当成黑匣子数据灌进去F1 出来。但调参的时候你会发现问题根本不在于学习率而在于你根本没搞懂每一层在干什么。这一章我们拆开看。2.1 BERT层动态词向量不是终点是起点中文 NER 的基础特征是字的语义表示。早期用 word2vec 静态向量一个“行”的向量永远是那个“行”不管它出现在“银行”还是“行走”里。BERT 根据整句上下文生成向量所以“银行”的“行”和“行走”的“行”在这一层就已经分开了。但这不代表 BERT 输出可以直接当标签。它输出的每个 token 向量是上下文相关的语言表征而不是任务相关的分类特征。比如你想识别人名BERT 知道这个字像“人话”但它不知道它是不是姓“张”。所以这层叫“特征提取器”不叫“分类器”。在工程上BERT 层通常使用预训练的中文模型输出维度一般是 768base 规模也有的源码里加了一个线性层降维到 bilstm 的 hidden_size。常见做法是取 BERT 最后一层输出作为每个字的向量而不是拿 CLS 或 pooler。from transformers import BertModel bert BertModel.from_pretrained( bert-base-chinese, output_hidden_statesFalse ) # 输入: input_ids, attention_mask # 输出: last_hidden_state [batch, seq_len, 768]这里有个参数值得注意output_hidden_states。如果你只想用最后一层设为 False 省显存但有的代码里会拼接最后四层输出效果能提升零点几个点代价是显存涨不少。我一般只在数据量比较大的时候才拼多层。2.2 BILSTM层双向上下文把边界特征压出来BERT 已经考虑了上下文了为什么还要 BILSTM我的理解是BERT 的注意力虽然能看全局但它给出的向量更偏“这个词在语言里怎么用”而 NER 需要的是“这个词在当前局部窗口中与前后标签的配合关系”。BILSTM 作为序列编码器能把局部的顺序依赖显式地再建模一遍尤其在处理连续命名实体时它对边界的敏感性比纯 BERT 更直接。BILSTM 的前向和后向各算出一个序列然后拼接。这一步会把每个字的隐藏状态变成两倍维度再过一个线性层映射到标签数。比如标签集合是 7 类B-PER, I-PER, B-ORG, I-ORG, B-LOC, I-LOC, O线性层输出就是 7 维。import torch.nn as nn class BILSTM(nn.Module): def __init__(self, input_size, hidden_size, num_labels): super().__init__() self.lstm nn.LSTM( input_size, hidden_size, num_layers1, bidirectionalTrue, batch_firstTrue ) self.linear nn.Linear(hidden_size * 2, num_labels) def forward(self, x): # x: [batch, seq_len, 768] output, _ self.lstm(x) logits self.linear(output) return logitsnum_layers一般设 1 就够。层数堆到 2 对 NER 任务收益很小显存却翻倍。hidden_size常见取 128 或 256数据量大取 256数据少取 128否则容易过拟合。BILSTM 还有一个容易被忽略的作用它让每个位置的输出既看到左边的字也看到右边的字。CRF 只看转移关系如果不做双向边界信息会被削弱不少。所以在源码里看到这里输出是hidden_size * 2不要奇怪那是拼接了前向和后向。2.3 CRF层标签转移约束才是NER的胜负手CRF 整个模型里最容易理解也是最容易写错的部分。它的作用不是改变每个字的分类得分而是给“标签之间的转移”加约束。比如 B-PER 后面不能直接接 I-ORGO 后面不能直接接 I-PER必须 B 开头。这些规则 CRF 能自动从数据里学出来不需要你手写。CRF 在训练阶段计算整个标签序列的分数包括每帧的发射分数来自 BILSTM 输出和转移分数CRF 自己学的一个方阵大小是 label_num × label_num。损失用真实序列分数减去所有可能序列的 logsumexp 分数。推理阶段用维特比解码找到分数最高的路径。from torchcrf import CRF crf CRF(num_tagsnum_labels, batch_firstTrue) # 训练时 loss -crf(logits, labels, maskattention_mask.bool()) # 推理时 decoded crf.decode(logits, maskattention_mask.bool())这里最常踩的坑是 mask。BERT 的 padding 部分也有标签但那是无效的。如果不把attention_mask传给 CRF模型会把 padding 位置也算进序列分数导致训练目标混乱。另外起始和结束标签要单独定义很多源码里忽略了这一点导致预测出来的序列以 I 开头后期还得手工修正。3. 从零跑通源码环境、数据格式与最小训练命令拿到一份 BERTBILSTMCRF 的中文 NER 源码先别急着跑。先确认三件事数据格式是不是你要的、依赖版本能不能对上、训练参数适不适合你的数据量。这三件只要有一件不对后面全是非预期行为。3.1 数据格式BIO标注与字符级别对齐中文 NER 大多数源码默认使用 BIO 标注体系。B 表示实体开始I 表示实体内部O 表示非实体。比如“小明在北京读书”标注后是小 B-PER 明 I-PER 在 O 北 B-LOC 京 I-LOC 读 O 书 O常见的三种后缀PER 人名、LOC 地名、ORG 机构名。源码里的标签表一般在data/labels.txt或代码里硬编码。你要做的第一件事是数一遍标签数量然后看模型输出层的num_labels是否一致。这个数错了训练时维度直接报错。文本和标签对齐是另一个容易翻车的地方。很多公开数据集是词级别的标注而 BERT 需要字级别的输入。你需要先做字符切分再对齐标签。切分最好按空格和标点不能用list(text)直接粗暴处理英文和数字会被拆成单个字母。def align_label(text, label_list): # 假设原始数据是 token 序列, 这里简化为字符 tokens [c for c in text] assert len(tokens) len(label_list), 标签长度与字符数不一致 return tokens, label_list如果你的原始标注是词组级别而不是字符级别需要写展开逻辑每个字继承其所属词组的标签词组首字用 B其余用 I。这一步别偷懒否则实体边界会偏移一个位置。3.2 环境与依赖版本匹配的坑提前说这个项目最常见的运行环境是 Python 3.8PyTorch 1.10transformers 4.x。当然你用更新的版本也能跑但要注意 API 变化。比如 transformers 4.30 以后BertModel.from_pretrained默认行为没变但一些老代码里的model.bert属性访问方式可能失效。我建议按源码requirements.txt装如果没有至少保证pip install torch transformers torchcrftorchcrf 是常用的 CRF 实现库。如果你不想依赖它也可以把 CRF 源码直接复制进项目里。网上流传的 CRF 实现通常只有一百多行核心是前向计算 logsumexp 和维特比解码。复制进来更可控调试时可以加打印。3.3 最小训练命令与参数表跑通一个最小实验我一般这样做python train.py \ --bert_dir ../pretrained/bert-base-chinese \ --train_data data/train.txt \ --dev_data data/dev.txt \ --max_len 128 \ --batch_size 16 \ --learning_rate 2e-5 \ --crf_lr 1e-3 \ --epochs 10参数表看这里max_len 训练/验证统一截断长度中文NER建议128起步长实体多可到256 batch_size 显存允许下尽量大16到32之间太小导致收敛不稳 learning_rate BERT层学习率通常2e-5到5e-5 crf_lr BILSTM和CRF层学习率可设1e-3是BERT层的几十倍 epochs 不宜过多5到15之间看验证集F1早停这里有一个关键思路BERT 层和下游层的学习率要分开。BERT 预训练过了学习率大了会把它学坏小一点只作适配BILSTM 和 CRF 是从零开始需要更大的步长。源码里如果不支持分别设置建议自己改一下优化器部分。optimizer torch.optim.AdamW([ {params: bert.parameters(), lr: 2e-5}, {params: bilstm.parameters(), lr: 1e-3}, {params: crf.parameters(), lr: 1e-3}, ])4. 项目说明里的关键设计标签映射、掩码、类别不均衡项目说明如果写得认真会强调三件事标签 id 怎么映射、padding 掩码怎么传、样本类别不均衡怎么处理。这三件事直接决定你是跑出 40% 的 F1 还是 90% 的 F1。4.1 标签映射与O类权重怎么处理绝大多数真实数据里O 类标签占绝对多数。人名、地名、机构名的占比可能不到 5%如果按默认交叉熵训练模型很容易把所有字都预测成 O整体准确率看起来很高但实体一个都抽不出来。因此要在损失函数里给实体类别加权或者让损失只管实体 token。CRF 损失天然处理了这一点吗并没有。CRF 是序列级损失它更关注标签转移但对单帧的类别不平衡不敏感。一个常见做法是在 CRF 发射分数输入前给实体类别的 logits 乘一个系数或者在数据采样时对包含实体的句子加权。label_weight torch.tensor( [1.0, # O 类权重低 5.0, # B-PER 5.0, # I-PER 10.0, # B-ORG机构名更难学 10.0, # I-ORG 6.0, # B-LOC 6.0] # I-LOC ).to(device) # 在 BILSTM 输出后直接乘权重再进 CRF logits logits * label_weight.unsqueeze(0).unsqueeze(0)但注意权重不要设太大否则模型会过度预测实体导致误报增多。我一般从 3-5 倍开始调验证集如果 P 高 R 低就降权重P 低 R 高就升权重。4.2 attention mask 与 padding 对 CRF 的影响刚才提到过CRF 必须要拿到掩码。这里再补充一个细节BERT 的 tokenizer 在中文环境下会自动把句子切分成字但如果句子长度超过max_lentokenizer 会截断这时标签也要同步截断。很多源码的 bug 在于数据读取时只截断了 input_ids没截断 labels导致长度不匹配。更隐蔽的问题是 tokenizer 添加的特殊 token。比如[CLS]和[SEP]占据两个位置标签序列前要补一个-100或对应 label 的 ignore index最后也要补。如果不补CRF 就把这两个 token 当成普通 token 参与训练。def encode_with_labels(text, labels, tokenizer, max_len): encoded tokenizer(text, truncationTrue, max_lengthmax_len) output_labels [-100] labels[:max_len - 2] [-100] # 对齐长度 output_labels output_labels[:max_len] # 如果长度不够, 补 -100 到 max_len output_labels [-100] * (max_len - len(output_labels)) return encoded[input_ids], encoded[attention_mask], output_labels-100在 PyTorch 的 CrossEntropyLoss 里默认是被忽略的标签。但 CRF 库不认-100它需要的是 0 到 num_labels-1 之间的数字。所以你要么把 padding 部分掩码掉要么在 CRF 调用时用mask参数。自己写 CRF 时尤其要小心这一点。4.3 早停与模型保存找最优epoch而不是最后epoch训练 BERT 类模型验证集 F1 通常不是单调上升的而是先涨后跌。如果你不做早停训练完直接拿最后一个 epoch 的模型大概率不是最优的。项目说明里的“模型保存策略”如果写清楚了一般会按验证集 F1 存历史最佳。best_f1 0.0 for epoch in range(epochs): train_one_epoch() f1 evaluate(dev_loader) if f1 best_f1: best_f1 f1 torch.save({ bert: bert.state_dict(), bilstm: bilstm.state_dict(), crf: crf.state_dict(), label2id: label2id, id2label: id2label, }, best_ner_model.pt)这里还要保存label2id和id2label否则预测阶段标签映射对不上。有的源码只保存模型权重加载时用代码里写死的标签表遇到标签表改了直接错乱。5. 避坑跑通容易跑好难——中文NER常见问题排查下面这几条是我实际跑这类项目时遇到的最高频问题。每条写清楚现象、原因和解决你按顺序对照能少走不少弯路。5.1 现象验证集F1很高测试集一塌糊涂原因通常有两个预处理不一致或数据泄漏。最常见的是把 dev 和 test 混在一起做预处理导致 tokenizer 字典里混进了测试数据信息。另一个是验证集里实体分布比例如果 dev 和 test 来自不同领域F1 自然对不上。解决确认 train/dev/test 三个文件完全独立且走同一套预处理函数。如果实体类别比如机构名相对测试集差异很大建议直接换成领域数据微调不要指望通用轨迹模型。5.2 现象CRF解码全是O模型不吐实体这个最让人崩溃。训练日志里 loss 在下降验证集准确率 99%但预测结果里所有标签都是 O。原因基本是类别不均衡没处理模型学到的就是一套“全O”也能把损失压到很低的捷径。解决先检查标签分布计算实体 token 占整体 token 的比例。如果低于 5%给实体类权重调高到 10 倍再训。如果加了权重还是全O查看 CRF 初始转移矩阵。有的 CRF 实现初始时对 I 类转移惩罚不足模型利用这个漏洞跳过所有 B 开头直接进入 I。5.3 现象BERT微调后灾难性遗忘具体表现是模型句子级预测还可以但把 BERT 继续微调几轮之后对通用语法理解反而下降实体边界更乱了。这通常是因为学习率没分开BERT 层被下游任务带偏了。解决BERT 层学习率调到 1e-5 以下或者干脆冻结 BERT 前几层只微调后几层。做法是在 optimizer 参数组里对不同层设置不同 lr。另外训练轮次控制在 5 以内BERT 微调在 NER 任务上很容易过拟合。5.4 现象长文本显存爆炸BERT 的显存占用随序列长度近似平方增长。中文 NER 语料里如果有大段公告、文书max_len256都可能 OOM。解决最直接的办法是设max_len128先把长句截断。如果你不想丢尾部信息可以分句跑然后合并结果。但合并时要处理好跨断句的实体边界比如“北京市”被切成了“北京”和“市”后半个没有上下文会预测成 O。更稳妥的是用滑窗重叠方式切分重叠 5-10 个字再取中间部分的预测。5.5 现象实体边界总是多一个字或少一个字这种错误在中文里最常见。比如“阿里巴巴集团”预测成“阿里巴巴”或“阿里巴巴集”。原因往往是训练数据里实体边界标注不一致或者 BILSTM 对边界字符的区分度不够。解决检查数据里是不是同一实体被标注成不同粒度。如果确认标注没问题就去调 CRF 的转移分数。一个实用技巧是把容易黏连的后缀词如“公司”“有限”“集团”作为特征加入模型输入。在 BERT 的基础上把这种词典特征拼进 BILSTM 输入能提升边界准确率。6. 进阶调优外部词典、后处理与轻量落地——把F1从85推到91的实操能跑到 85 分的 F1 已经能交差但如果要上生产这个数字只能算及格。我一般会再加三招它们能带来三到六个百分点的提升。6.1 加一个外部词典特征能救回不少低频实体BERT 对常见人名地名很准但对产品名、药品名、小众机构名很弱因为这些词在预训练语料里出现少。如果你手里有领域词典可以把它融入模型。一种简单做法是在 BILSTM 输入处拼一个“该字是否在词典中”的特征维度为 1指示当前字符是否匹配某个词典词的首字或中间字。更有效的做法是 Lexicon 接入的偏门方案把每个字符在词典中能匹配到的词边界信息如 B、I、E、S作为额外的标签序列编码成向量拼接进 BERT 输出。这个改动工作量不大但能显著提升领域实体召回。我项目里曾经给公司内部产品名词典做了这个特征F1 从 83 直接到了 88。6.2 阈值与解码后处理宁可少抽不可错抽CRF 本身的解码是找整条路径最优但实体的置信度并没有显式输出。后处理可以做两层第一对 CRF 解码结果里的连续实体 span计算该 span 内所有 token 发射概率的均值低于阈值则丢弃第二对跨标点符号的长实体做核验如果中间有逗号或顿号大概率不是同一个实体。def filter_low_confidence(decoded, logits, label_list, threshold0.5): # decoded: [seq_len] 标签id # logits: [seq_len, num_labels] 发射分数 spans [] start -1 current_label None for i, tag_id in enumerate(decoded): label label_list[tag_id] if label.startswith(B-): if start ! -1: spans.append((start, i - 1, current_label)) start, current_label i, label[2:] elif label.startswith(I-) and start ! -1: continue else: if start ! -1: spans.append((start, i - 1, current_label)) start -1 results [] for s, e, label in spans: avg_score logits[s:e1, :].max(dim-1).values.mean().item() if avg_score threshold: results.append((s, e, label)) return results阈值我一般设 0.3 到 0.5。如果线上业务对误报特别敏感可以调到 0.6。这本质上是拿召回换精确率具体数值用小范围测试集调别拍脑袋。6.3 轻量落地蒸馏到小型模型后的验证方法BERT 模型部署到 CPU 上单条文本延迟可能超过 100 毫秒吞吐上不去。常见做法是把它蒸馏到一个更小的模型比如用 BiLSTMCRF 直接学习 BERT 的输出分布。但蒸馏后的模型往往会掉点而且掉点不均匀对长实体和生僻实体影响更大。验证蒸馏模型时不要只看整体 F1要按实体类别拆开看。我一般会写一个评估脚本分别列出 PER、LOC、ORG 的 P/R/F1。如果某一类掉点超过 3 个点说明蒸馏时该类样本权重不够需要针对性增加该类别的蒸馏损失。蒸馏后补一个实体级阈值校准通常能把差距拉回到 1 个点以内。最后说一个我自己的习惯每次跑完一个实验把模型输出里所有错误的样例存下来按错误类型分类——边界错误、漏召回、误报。这个习惯比调参本身更能提升效果。因为错误样例看多了你会发现大部分问题都出在数据标注质量上而不是模型结构。我最后一次从 89 提到 91靠的就是修正了 200 条标注不一致的数据而不是换模型。希望这篇笔记能帮你的中文 NER 项目少走几步弯路。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑