BERT-BiLSTM-CRF中文命名实体识别实战:PyTorch代码全解析

发布时间:2026/9/28 2:52:24
BERT-BiLSTM-CRF中文命名实体识别实战:PyTorch代码全解析 简介基于PyTorch的BERT-BiLSTM-CRF命名实体识别项目提供可直接运行的完整代码与配套数据面向NLP学习者、算法工程师及科研人员用于从文本中自动抽取人名、地名、组织机构等具有特定语义的实体快速验证主流序列标注方案。模型由BERT提供深度上下文表示BiLSTM捕捉双向序列特征CRF通过全局优化输出标签序列在实体边界与类别判定上比独立分类更稳定可避免孤立标签决策带来的不一致问题。资源包共23个文件包含9个Python脚本负责数据预处理、BERT输入格式转换、模型定义、训练与预测、5个配置文件、5个编译缓存文件及3个文本数据说明整体仅341KB轻量易用。项目完整覆盖[CLS]/[SEP]标记添加、长度截断填充、Hugging Face Transformers加载预训练权重、CRF解码与负对数似然损失计算等环节并给出训练与评估流程可直接启动训练或在预训练结果上继续调优。目前已有2408人学习适合用于课程设计、算法复现或作为扩展研究的基线系统。1. BERT-BiLSTM-CRF中文命名实体识别一份拿来就能跑的 PyTorch 实战代码包搞 NER 的人应该都有过这种经历用纯 BiLSTM-CRF 在中文上做到头也就 85 左右的 F1换 BERT 又不知道怎么把 Sequence 标注和 CRF 的转移约束接得顺畅。这次拆的这份基于 PyTorch 的 bert-bilstm-crf-ner 完整代码包把预训练模型、双向序列编码、条件随机场三层直接串成一个端到端训练脚本数据、模型、推理全给齐下载下来改改数据路径就能跑。它适合两类人一是要做中文命名实体识别但不想从零搭管线的工程师二是想搞清楚 BERT 后面接 CRF 到底比 Softmax 好在哪里、损失函数怎么算的进阶学习者。下文按模型拆解、数据格式、训练推理、踩坑记录、进阶调优五部分来讲重点说清楚每段代码在做什么、参数怎么改、哪些地方容易翻车。这份资源不是那种只给几个 .py 文件的半成品。压缩包里至少包含了标注好的中文数据集、基于 HuggingFace transformers 的 BERT 加载代码、BiLSTM 层和 CRF 层的实现以及训练和推理脚本。我按自己的习惯把整个流程重新捋了一遍跑通之后发现它的设计思路很典型BERT 负责把字符转成语义向量BiLSTM 负责捕捉上下文顺序CRF 负责保证标签序列像 BIO 这种规则不出错。简单说这就是目前工业界做中文序列标注最稳妥的基线方案之一。2. 模型结构拆解BERT 特征层、BiLSTM 上下文层与 CRF 约束层2.1 三个模块为什么按这个顺序堆叠先聊原理不然你改代码的时候不知道在改什么。BERT-BiLSTM-CRF 这个名字已经说明了层次关系文本先进入 BERT得到每个 token 的上下文相关表示然后 BiLSTM 在 BERT 输出之上再做一次双向建模捕获更贴近当前任务的局部序列特征最后 CRF 层对发射分数emission score做全局最优路径解码。为什么要叠这三层而不是只用其中一个纯 BERT Softmax 也能做 NER但 Softmax 对每个 token 独立预测标签完全不知道 B-PER 后面必须接 I-PER 这种约束纯 BiLSTM-CRF 又缺少预训练语义知识遇到人名、地名这种需要常识的实体特征不如 BERT 丰富。BERT 加 CRF 是这两年最常见的组合中间再插一个 BiLSTM是因为部分场景下 BERT 的向量对序列标注还不够「尖锐」BiLSTM 能根据当前任务的标签分布重新校准上下文CRF 的转移矩阵再兜底。这个代码包采用的就是这个三层结构训练时 CRF 计算整个序列的对数似然推理时用维特比解码而不是逐 token 取 argmax。2.2 标签体系与数据格式BIO 标注先定清楚在敲代码之前先看数据格式。这份资源里的中文 NER 数据集用的是最常见的 BIO 标注体系B 表示实体开始I 表示实体内部O 表示非实体。实体类型一般有人名PER、地名LOC、组织机构名ORG有的数据集还会加时间、职位等类型。数据格式通常是一行一个字字和标签用空格或者制表符隔开句子之间用空行分隔。示例大概长这样李 B-PER 小 I-PER 明 I-PER 在 O 北 B-LOC 京 I-LOC 出 O 差 O拿到数据之后第一件事是把这类文本文件转成模型需要的 id 序列。你需要建立两个映射表一个是字符到 BERT token 的映射另一个是标签到整数的映射。注意 BERT 用的是 WordPiece 分词中文虽然大多单字成 token但一些特殊符号会被拆成多个 token这会导致标签序列长度和 token 序列长度不一致后面要按 token 对齐逐个分配标签。label2id {O: 0, B-PER: 1, I-PER: 2, B-LOC: 3, I-LOC: 4, B-ORG: 5, I-ORG: 6} id2label {v: k for k, v in label2id.items()} num_labels len(label2id)这里的 label2id 决定了 CRF 的标签数量。如果你的数据集有自定义实体类型只需要扩展这个字典但要注意字典的 key 必须和标注文件里的字符串一模一样否则读数据时会因为 KeyError 中断。我处理过一份标注里混了 B-PER 和 B-per 的数据清洗了半天教训就是先统计标签种类再建映射表。2.3 核心模型代码BERT 输出过 BiLSTM 再过 CRF这份代码包的模型定义部分结构清晰我直接拆开说。核心模型类一般长这样class BertBilstmCrf(nn.Module): def __init__(self, bert_dir, num_tags, lstm_hidden256, dropout0.5): super().__init__() self.bert BertModel.from_pretrained(bert_dir) self.lstm nn.LSTM( input_sizeself.bert.config.hidden_size, hidden_sizelstm_hidden, num_layers1, batch_firstTrue, bidirectionalTrue ) self.dropout nn.Dropout(dropout) self.fc nn.Linear(lstm_hidden * 2, num_tags) self.crf CRF(num_tags, batch_firstTrue)def forward(self, input_ids, attention_mask, labelsNone): bert_output self.bert(input_idsinput_ids, attention_maskattention_mask)[0] lstm_output, _ self.lstm(bert_output) lstm_output self.dropout(lstm_output) emissions self.fc(lstm_output) if labels is not None: loss -self.crf(emissions, labels, maskattention_mask.bool(), reductionmean) return loss else: return self.crf.decode(emissions, maskattention_mask.bool())逐段解释一下逻辑。BertModel.from_pretrained(bert_dir)加载预训练权重bert_dir指向下载到本地的模型目录。[0]取的是 BERT 最后一层所有 token 的 hidden state形状是(batch_size, seq_len, hidden_size)。BiLSTM 的输入就是这个三维张量bidirectionalTrue意味着输出维度翻倍所以全连接层的输入要用lstm_hidden * 2。CRF 层接收的是全连接输出的发射分数注意这里用的是torchcrf库的实现batch_firstTrue必须和 LSTM 保持一致。maskattention_mask.bool()这一行值得单独说。CRF 的训练需要 mask 来屏蔽 padding token否则模型会把 padding 位置当成有效标签去计算转移概率导致损失异常。而且attention_mask默认是 int 类型而 torchcrf 要求 mask 是 bool 类型不转换的话会在masked_fill_时报类型错误。这个坑很隐蔽因为报错信息不一定直接指出类型问题。训练阶段返回负对数似然也就是-self.crf(emissions, labels, mask...)PyTorch 的优化器默认做梯度下降所以取负号把最大化似然变成最小化损失。推理阶段直接调用self.crf.decode该方法内部使用维特比算法求出全局最优标签序列不需要额外写解码逻辑。这一点比手动实现 CRF 要省心得多。3. 跑通训练与预测数据加载、超参数选择与推理解码3.1 数据加载与预处理Tokenizer 和标签对齐数据加载是这份代码包里最需要细心处理的部分。BERT 的 tokenizer 会把一个词切成多个子词中文场景下虽然多数是单字但如果文本里有「API」「iPhone」这种中英混合词token 就会被拆开标签就必须跟着变长。from transformers import BertTokenizer tokenizer BertTokenizer.from_pretrained(bert_dir) def encode_line(chars, tags, tokenizer, label2id, max_len128): input_ids [] label_ids [] for char, tag in zip(chars, tags): sub_tokens tokenizer.tokenize(char) if not sub_tokens: sub_tokens [tokenizer.unk_token] input_ids.extend(tokenizer.convert_tokens_to_ids(sub_tokens)) tag_id label2id[tag] label_ids.append(tag_id) if len(sub_tokens) 1: label_ids.extend([label2id[O]] * (len(sub_tokens) - 1)) input_ids input_ids[: max_len - 2] label_ids label_ids[: max_len - 2] input_ids [tokenizer.cls_token_id] input_ids [tokenizer.sep_token_id] label_ids [label2id[O]] label_ids [label2id[O]] return input_ids, label_ids这里做了三件关键事第一用tokenizer.tokenize(char)把每个字符切一遍拿到子词序列如果子词多于一个后续子词的标签统一补 O因为 BERT 子词切分后的续接部分不属于独立实体第二截断时留下两个位置给[CLS]和[SEP]这是 BERT 的输入规范第三序列首尾的标签都设为 O因为[CLS]和[SEP]不参与实体判定。这种处理方式有个前提假设数据集的标注单位是「字」。如果你的数据集是按词标注的就不能逐字 tokenize得先做整词切分再映射标签。这份资源里的中文数据是按字标注的所以直接跑没问题。从代码包里看它的数据尺寸不算大训练一轮很快很适合先用 CPU 验证逻辑、再上 GPU 调参。3.2 训练参数BERT 学习率和下游层学习率分开设训练脚本本身不复杂但有几个参数直接决定收敛质量。最常见的做法是 BERT 层用较小的学习率BiLSTM 和 CRF 用较大的学习率因为预训练参数只需要微调而随机初始化的层需要更快收敛。from transformers import AdamW, get_linear_schedule_with_warmup optimizer AdamW([ {params: model.bert.parameters(), lr: 2e-5}, {params: model.lstm.parameters(), lr: 1e-3}, {params: model.fc.parameters(), lr: 1e-3}, {params: model.crf.parameters(), lr: 1e-3} ]) total_steps len(train_dataloader) * epochs scheduler get_linear_schedule_with_warmup( optimizer, num_warmup_stepsint(total_steps * 0.1), num_training_stepstotal_steps )参数分组的写法是 PyTorch 优化器的标准用法。model.bert.parameters()只包含 BERT 部分的权重2e-5是微调预训练模型最常用的学习率再大容易出现灾难性遗忘。BiLSTM、全连接和 CRF 层因为是从零开始训练用1e-3这种常规学习率。warmup 比例设为 10% 是常见做法让模型在前几个 step 用小学习率稳定下来避免一开始就迈大步。关于环境配置这份代码对 PyTorch 版本要求不苛刻1.8 以上都能跑关键在于 transformers 版本和 torchcrf 的兼容性。PyTorch 安装本身是另一个话题但如果你用的是 GPU 版记得 CUDA 版本要和 PyTorch 编译版本对应上比如 CUDA 11.8 配cu118后缀的 wheel 包装错版本会提示找不到libcudart.so。BERT 模型的首次加载会自动从 HuggingFace 下载权重如果网络不稳也可以手动下载后放到本地目录再把bert_dir指向那个目录。3.3 推理解码维特比路径输出训练完成后做预测注意推理模式和训练模式的差异。在训练时 CRF 计算的是整条路径的似然推理时则调用维特比算法寻找概率最高的路径。这份代码包的预测脚本核心只有两行model.eval() with torch.no_grad(): pred_tags model(input_ids, attention_mask)这里的model(input_ids, attention_mask)走的是forward的 else 分支调用self.crf.decode。返回的pred_tags是一个 list of list每个元素是标签 id 序列。拿到之后要通过id2label映射成实体名再配合原始句子把实体边界提取出来。def extract_entities(chars, tags): entities [] entity_type None start -1 for i, tag in enumerate(tags): if tag.startswith(B-): if entity_type: entities.append((entity_type, .join(chars[start:i]))) entity_type tag[2:] start i elif tag.startswith(I-): continue else: if entity_type: entities.append((entity_type, .join(chars[start:i]))) entity_type None if entity_type: entities.append((entity_type, .join(chars[start:]))) return entities这段的实体合并逻辑要注意两点遇到I-但前面没有B-开头的异常情况要自己做好防御性处理遇到O要把之前累积的实体先输出再重置状态。我见过不少人在这一步直接跳过I-不管导致连续两个B-之间的实体漏掉。准确的做法就像上面代码一样只在B-和O时做实体切分。4. 避坑与排查BERT 不收敛、CRF 报错、显存溢出的真实处理记录4.1 训练 loss 剧烈震荡不下降检查学习率和标签对齐现象训练几十个 step 之后loss 在 3 到 15 之间反复横跳完全没有下降趋势。原因最常见的是两个。一是 BERT 层的学习率设得太大比如用了1e-3甚至5e-4预训练权重直接被带偏二是标签对齐出了问题某个字符被 tokenizer 切成多个子词时后续子词如果分配成了B-PER而不是OCRF 就会学到错误的转移规则无法收敛。解决先把 BERT 参数的学习率降到2e-5到5e-5之间。然后打印一条数据检查标签序列和 token 序列长度如果长度不同说明对齐逻辑有问题。我自己的习惯是写一个断言assert len(input_ids) len(label_ids)不想每次都靠肉眼查。4.2 Python 报 bool 类型错误mask 必须显式转换现象训练刚开始跑第一个 batch 就报错错误信息类似expected torch.BoolTensor but got torch.LongTensor或者masked_fill_报类型不匹配。原因attention_mask从 dataloader 出来时是torch.long类型但torchcrf的mask参数要求torch.bool。两个库设计的接口不统一训练脚本里没做类型转换。解决在喂给 CRF 前的所有 mask 使用处统一加.bool()。不仅仅是 forward 里要做如果自己写 CRF 实现也要在计算 mask 后马上转换养成习惯。4.3 推理结果出现 I-PER 开头或连续的 B-PER转移矩阵约束失效现象模型预测出来的标签序列里出现了I-PER前面不是B-PER或者B-PER后面直接跟B-PER的情况。原因如果你替换了 CRF 层或者用了自己的实现且没有正确初始化转移矩阵约束就是无效的。但即使 CRF 正常工作如果训练数据的标签本身就存在这种非法序列模型学到的转移概率也会允许这种路径存在。解决检查训练数据的标注质量统计每一条序列中标签转移对比如O-I-PER这种非法组合出现的次数。合法的转移表应该只允许 O/B/I 之间有限组合。如果你用的是torchcrf默认情况下它的转移约束是数据驱动的你需要在数据清洗阶段就把非法序列修掉或者预处理时自动把孤立的 I-PER 改成 B-PER。4.4 显存溢出或训练速度异常慢max_len 和 batch_size 没调好现象CUDA out of memory或者 BERT 单步耗时明显超过预期。原因BERT 的显存占用和序列长度呈二次关系因为 self-attention 的计算量是 O(n²)。max_len512和max_len128的显存占用能差四倍以上。批量大小也没根据显存去调8 的 batch 在 6G 显存上跑 512 长度基本是必然溢出。解决常规做法是先设max_len128、batch_size16跑通之后再往上加。显存比较紧张就降到batch_size4、max_len64用梯度累积来补有效批量大小。这份代码包默认参数偏保守如果你的 GPU 好可以逐步放宽但不要一上来就拉满。5. 进阶调优冻结 BERT 底层、验证脚本与上手顺序模型能跑通之后想再提精度或者省显存有三个技巧值得尝试。第一个技巧是冻结 BERT 的低层参数只微调高层。BERT 的低层学到的是通用语法特征对 NER 这种任务微调它们带来的收益不大但显存开销不小。常见的做法是把 BERT 分成几个阶段只放开最后两层for name, param in model.bert.named_parameters(): if layer in name: layer_num int(name.split(.)[3]) if layer_num 10: param.requires_grad False注意 BERT base 一共 12 层name.split(.)[3]取到的是层序号。这样改完反向传播的梯度图会变小显存占用和训练时间都能减少代价是最终 F1 可能低 0.5 到 1 个点。对实体类型简单的数据集这个交换很划算。第二个技巧是写一个验证脚本专门统计 F1 值。训练脚本通常只打印 loss但 NER 的评估必须看实体级别的精确率、召回率和 F1只盯准确率容易被 O 标签占比高带偏。from seqeval.metrics import classification_report true_labels [[id2label[l] for l in seq if l ! 0] for seq in label_ids_list] pred_labels [[id2label[l] for l in seq if l ! 0] for seq in pred_ids_list] print(classification_report(true_labels, pred_labels))这里用seqeval库它专门处理序列标注的评估按实体边界和类型分别统计而不是按 token 统计。注意过滤掉 padding 的 0 标签否则 PAD 的 O 会被算进负样本在短句多的数据集上会虚高。第三个技巧是搞清楚 torchcrf 的边界限制。这个库实现的 CRF 没有显式的状态转移约束字典也就是说它完全靠训练数据学转移矩阵不像一些中文 NER 框架那样在 CRF 里内置了 BIO 的合法转移表。因此你要对自己的数据质量负责训练前先检查一遍有没有非法标签序列否则模型真的会把O - I-PER学到高概率。这份代码包给我的整体感觉是结构规整核心逻辑干净适合作为生产项目的第一版基线。我先在 CPU 上用max_len64、batch_size8跑通全部流程再上 GPU 调正式参数整个过程没有遇到需要改架构的硬伤。从那以后我每次跑新的中文 NER 任务都强制自己先过一遍这份代码的标签对齐和 mask 检查这两步没问题再谈调参。希望你也能把它跑通在自己的数据上快速出基线希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

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

尧图内容编辑团队

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

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

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

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

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

网站改版的5个关键决策

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

获取专属建站方案

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

立即免费咨询