命名实体识别实战:BERT-BiLSTM-CRF 原理、代码与上线避坑指南
简介面向中文自然语言处理与深度学习入门者这套代码项目演示了如何用BERT预训练模型结合BiLSTM和CRF完成命名实体识别任务覆盖数据清洗、分词、标签转换、模型训练、评估及在线预测全链路。压缩包共55个文件以Python脚本为主体34个py文件包含核心模型实现、训练辅助模块、服务端与客户端测试脚本同时提供11张运行效果图、4个Markdown说明文档和若干配置文件整体仅482KB结构紧凑。代码在bert_lstm_ner.py中完成BERT与BiLSTM-CRF的融合并提供data_process.py、train_helper.py等工具方便读者理解序列标注的数据流与模型封装配套的server和client模块则实现了简单的HTTP预测服务便于快速体验。目前已有85人学习浏览适合具备一定Python和深度学习基础、想动手复现或改造NER系统的开发者可直接参考其工程组织与接口设计。1. 从字序列到谁是谁BERT-BiLSTM-CRF 这套框架到底在解决什么做信息抽取的人早晚会遇到同一道坎模型读懂了句子却不知道哪几个字该圈成一个实体。BERT-BiLSTM-CRF 就是命名实体识别NER落地时最稳的经典组合——BERT 给每个字配上有上下文的向量BiLSTM 沿时间步再扫一遍双向信息CRF 用转移矩阵把B-PER 后面必须跟 I-PER这类序列规则写进损失。拿到这类源码时模型结构反而是最不用动的部分真正决定能不能跑通的是标签对齐、损失计算和实体解码。下文按为什么这么组合、最小工程怎么搭、参数与推理的坑、上线前查什么四层推进给已经跑过 BERT 分类、准备转向序列标注的工程师指一条能落地的路线。2. 为什么是 BERT BiLSTM CRF命名实体识别三层各自干什么先给结论这套组合不是无脑把三个模型叠起来而是三个组件分别回答了 NER 的三个问题——每个字拿什么向量表示、当前位置该贴什么标签、标签之间怎么衔接才算合法。三个问题拆开都能单独回答但组合在一起才是几千条标注数据下效果最稳的默认方案。网上对BERT 之后还要不要 BiLSTM吵了很多年实践里我的判断是数据量小、实体边界依赖局部形态时加一层 BiLSTM 通常能换来 F1 的稳定上涨代价只是多出几千个可学习参数。2.1 BERT 层让字第一次带上上下文词向量时代最大的尴尬是一词一义。小米在小米粥和小米公司里共用同一个向量实体识别模型只能靠字面硬猜。BERT 模型把每个 token 放进整句话里过十几层 Transformer同一个字在不同上下文里拿到不同向量这是 NER 效果质变的根源。中文场景下按字切分一个字就是最小粒度省去了分词错误向下传播的问题。from transformers import AutoTokenizer, AutoModel tokenizer AutoTokenizer.from_pretrained(bert-base-chinese) model AutoModel.from_pretrained(bert-base-chinese) inputs tokenizer(小米公司在北京发布新品, return_tensorspt) outputs model(**inputs) # outputs.last_hidden_state 形状: [batch_size, seq_len, hidden_size]说明last_hidden_state里每个位置对应一个 token 的上下文向量BERT-BiLSTM-CRF 源码中 BiLSTM 的输入就是它。参数上bert-base-chinese 的 hidden_size 是 768所以后续 BiLSTM 的 input_size 直接取bert.config.hidden_size不要写死 768否则换 multilingual 或领域预训练模型时又要连带改。李沐讲 BERT 的专题里反复强调过一个观点预训练模型的收益大多来自表示替换而非结构加深这在 NER 上体现得最明显——换成通用中文 BERT 后不改任何下游结构F1 往往就能比随机初始化的 BiLSTM-CRF 高七八个点。2.2 BiLSTM 层在时间步上再做一次双向归纳BERT 已经带上了上下文BiLSTM 再扫一遍扫的是当前的序列特征。实体类型经常依赖局部形态人名偏爱姓加名结构地名偏爱省、市、县结尾组织名里高频出现集团、银行、研究院。这类 n-gram 形态不是靠全局注意力记住的而是靠局部滑窗归纳出来的。BiLSTM 在这里相当于一个参数可控的序列特征抽取器把 BERT 输出压缩成更贴近标签任务的表示。self.bilstm nn.LSTM( input_sizebert.config.hidden_size, # 768 hidden_sizebilstm_hidden, # 256 num_layers1, batch_firstTrue, bidirectionalTrue, ) self.classifier nn.Linear(bilstm_hidden * 2, num_labels)说明bidirectionalTrue时每个位置拼接正反向两个 hidden state所以 Linear 的输入维度要写成bilstm_hidden * 2。常见源码把 num_layers 写成 2 或 3实际收益很小反而让梯度在长序列上更难传我一般固定在 1 层把省下的显存给 batch size。dropout 放在 LSTM 输出与分类层之间0.1 到 0.3 之间调不要对 BERT 输出做太大扰动。2.3 CRF 层把标签转移写进损失而不是写进规则独立 softmax 解码对每个位置单独取最大概率完全不看相邻标签。于是推理结果里会出现 I-PER 开头、O 之后直接接 I-ORG、同一实体中间标签跳变这类非法序列。CRF 的做法是加一个可学习的转移矩阵形状是num_labels × num_labels训练时把整条路径的分数当作目标推理时用维特比找全局最优路径。非法转移不是靠人工规则禁掉的而是转移矩阵里对应位置的分数被训练数据压下去。对比项位置独立 Softmax线性链 CRF标签间依赖不考虑显式建模相邻标签转移解码方式每位置取 argmax维特比全局最优训练目标逐位置交叉熵整条序列负对数似然非法标签序列可能出现在结果里被转移分数压制额外代价无一个转移矩阵复杂度 O(n·L²)n 是序列长度L 是标签数量。标签数量在 20 以内时CRF 的复杂度完全不是瓶颈如果任务有几十上百种细粒度标签才需要考虑全局指针之类的替代方案。2.4 标签体系先定下来BIO 与 BIOES写代码之前先定标签集。BIO 用 B 表示实体开头、I 表示实体内部、O 表示非实体BIOES 在此基础上多出 E结尾和 S单字实体。源码里默认用 BIOES 的占多数因为实体边界信息更显式对只有一个字的实体如京也有独立的 S 标记CRF 学起来更轻松。LABELS [O, B-PER, I-PER, B-ORG, I-ORG, B-LOC, I-LOC] label2id {l: i for i, l in enumerate(LABELS)} id2label {i: l for l, i in label2id.items()}label2id 一旦定下来就不要中途改动训练、验证、推理三处的 id2label 必须来自同一个映射文件这是源码里最容易被改坏的全局状态。给数据打标签时O 的 id 必须为 0后面写 padding 逻辑时会依赖这个约定。3. 命名实体识别最小工程从 CONLL 数据到训练循环拿到源码的第一步不是读论文而是把数据喂进 DataLoader。序列标注的标准输入是字 标签的等长序列工程上绕不开数据加载、模型拼装、loss 计算三个环节。这一章给出一套能直接替换进大多数源码工程的最小实现。3.1 数据格式与加载CONLL 风格文件怎么读NER 数据最常见的落盘格式是 CONLL 风格每行一个字加一个标签空行分隔句子。中文开源数据如 CLUENER一般长这样小 B-PER 明 I-PER 现 O 在 O 北 B-LOC 京 I-LOC 上 O 班 Odef load_conll(path): samples [] tokens, labels [], [] with open(path, encodingutf-8) as f: for line in f: line line.strip() if not line: if tokens: samples.append((tokens, labels)) tokens, labels [], [] continue parts line.split() tokens.append(parts[0]) labels.append(parts[-1]) return samples说明空行表示一句话结束内层 if 负责把上一句收尾。parts[0]取字、parts[-1]取标签中间列词性、位置等直接跳过。改别人源码时最容易踩的坑是列顺序有的文件把标签放在第二列有的放在最后一列读进来之前先用head命令确认格式而不是直接改读取逻辑。3.2 模型拼装BERT 主干 BiLSTM CRF 的 PyTorch 写法完整模型代码量不大核心就四段加载 BERT、双向 LSTM、分类头、CRF。CRF 层大部分源码直接复用 torchcrf自己实现反而容易在前向算法上出错。import torch import torch.nn as nn from torchcrf import CRF from transformers import AutoModel class BertBiLstmCrf(nn.Module): def __init__(self, bert_dir, num_labels, bilstm_hidden256, dropout0.1): super().__init__() self.bert AutoModel.from_pretrained(bert_dir) self.bilstm nn.LSTM( input_sizeself.bert.config.hidden_size, hidden_sizebilstm_hidden, num_layers1, batch_firstTrue, bidirectionalTrue, ) self.dropout nn.Dropout(dropout) self.classifier nn.Linear(bilstm_hidden * 2, num_labels) self.crf CRF(num_labels, batch_firstTrue) def forward(self, input_ids, attention_mask, label_idsNone): last_hidden self.bert( input_idsinput_ids, attention_maskattention_mask ).last_hidden_state seq_out, _ self.bilstm(last_hidden) logits self.classifier(self.dropout(seq_out)) mask attention_mask.bool() if label_ids is not None: nll -self.crf(logits, label_ids, maskmask, reductionmean) return nll return self.crf.decode(logits, maskmask)说明forward 里用一个 mask 同时服务两个目的——attention_mask 负责 BERT 的 padding 遮蔽转成 bool 后作为 CRF 的 mask告诉 CRF 哪些位置是真实 token。label_ids 不为空时走训练分支返回负对数似然为空时走推理分支返回每句话的标签序列。训练和推理共用一个 forward不容易出现两边行为不一致。3.2.1 CRF 的损失为什么不是交叉熵初学者最容易问把 logits 拿去算交叉熵不就行了吗为什么要多套一层 CRF区别在目标。交叉熵逐位置优化模型只需要把每个位置猜对CRF 优化的是整条序列的路径分数等于每个位置猜对加上相邻标签组合合法。torchcrf 内部维护一个num_labels × num_labels的转移矩阵训练时计算负对数似然推理时跑维特比。转移矩阵的初始值不影响最终效果训练中会被数据校正真正影响结果的是 mask 是否准确mask 漏掉 padding 位置CRF 就会在 PAD 上学习无意义的转移。3.3 训练循环与分层学习率训练时最值得抄的配置是分层学习率。BERT 是预训练权重学习率大了直接破坏学到的表示BiLSTM 和分类头是随机初始化可以给更大的步长CRF 只有转移矩阵几十几百个参数可以再放宽。参数分组学习率理由bert2e-5预训练权重只做微调bilstm classifier5e-4随机初始化收敛快crf1e-3参数量极小需要相对大的更新步长from torch.optim import AdamW from transformers import get_linear_schedule_with_warmup optimizer AdamW([ {params: model.bert.parameters(), lr: 2e-5}, {params: model.bilstm.parameters(), lr: 5e-4}, {params: model.classifier.parameters(), lr: 5e-4}, {params: model.crf.parameters(), lr: 1e-3}, ], weight_decay0.01) scheduler get_linear_schedule_with_warmup( optimizer, num_warmup_stepstotal_steps * 0.1, num_training_stepstotal_steps ) for step, batch in enumerate(dataloader): loss model(**batch) # forward 内部返回 NLL loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm5.0) optimizer.step() scheduler.step() optimizer.zero_grad()说明loss.backward之后必须做梯度裁剪。Transformer 叠加 LSTM 之后梯度范数很容易冲到几十甚至上百不裁剪的话偶尔一个样本就会让 CRF 转移矩阵跳飞表现为训练 loss 突然 NaN 或 F1 骤降。max_norm 取 5.0 是我常用的起点CRF 层参数量小对裁剪阈值不敏感。4. 训练参数与推理解码源码里决定 F1 的细节模型能跑起来只是第一步。同样的源码有人 F1 到 92有人停在 85差距几乎全在标签对齐、mask、解码这三个细节上。这一章把参数表和实现细节并列着讲。4.1 一套可以直接抄的参数表参数推荐起点说明max_len128中文一句平均 30~50 字128 足够覆盖 95% 样本batch_size16 / 32显存不够时先降 batch而不是降 max_lenepochs5 ~ 10配合验证集早停看 F1 不再涨就停warmup 比例0.1前 10% 步数线性升到目标学习率梯度裁剪5.0防止 CRF 层梯度爆炸验证频率每 500 步不要等到 epoch 结束才看验证集max_len 不是越大越好。序列标注的注意力复杂度随长度平方增长128 升到 256显存和训练时间几乎翻倍而实体级 F1 的提升通常不到一个点。如果文本确实长优先考虑切窗而不是加 max_len。4.2 标签对齐词表切分带来的隐形偏移这是源码里最隐蔽的错误来源。BERT 的 tokenizer 会做三件事在句首加 [CLS]、句尾加 [SEP]、补 padding 到 max_len。英文还会把词切成子词。任何一步没有同步处理标签序列训练时就出现字对不上标签模型却不会报错只是 F1 上不去。def encode_with_alignment(text_tokens, label_ids, tokenizer, max_len): input_ids [tokenizer.cls_token_id] aligned_label_ids [0] # CLS 位占一个标签位 for tok, lbl in zip(text_tokens, label_ids): input_ids.append(tokenizer.convert_tokens_to_ids(tok)) aligned_label_ids.append(lbl) input_ids.append(tokenizer.sep_token_id) aligned_label_ids.append(0) # SEP 位同理 attention_mask [1] * len(input_ids) pad_len max_len - len(input_ids) if pad_len 0: input_ids input_ids[:max_len] aligned_label_ids aligned_label_ids[:max_len] attention_mask attention_mask[:max_len] else: input_ids [tokenizer.pad_token_id] * pad_len aligned_label_ids [0] * pad_len attention_mask [0] * pad_len return input_ids, attention_mask, aligned_label_ids说明CLS 和 SEP 位置各占一个标签位补 0 也就是 O 的 idpadding 位置标签也补 0但 attention_mask 为 0CRF 不会在这些位置上计算转移。这里依赖 2.4 的约定O 的 id 是 0。如果源码里用nn.CrossEntropy实现简化版 NER通常改用 -100 填充 padding 位置的标签配合ignore_index-100效果等价两种写法不要混用。如果做英文 NERtokenizer 会把 running 切成 run 加 ##ning一个词对应多个标签。这时要么用 tokenizer 返回的 offset_mapping 反查每个子词对应原词的标签要么在加载数据时直接拒绝子词切分跨标签的样本。大多数中文源码不处理这个分支一旦换成英文数据就静默出错。4.3 推理解码维特比之后还要做实体还原源码里的 decode 分支一般调用crf.decode返回每个 token 的标签 id但 NER 的最终产物是实体列表类型加起止位置中间还差一步合并。def entities_from_tags(tag_ids, id2label): entities [] ent_type, ent_start None, None for i, tag_id in enumerate(tag_ids): label id2label[tag_id] if label.startswith(B-): if ent_type is not None: entities.append((ent_type, ent_start, i)) ent_type, ent_start label[2:], i elif label.startswith(I-): if ent_type ! label[2:]: if ent_type is not None: entities.append((ent_type, ent_start, i)) ent_type None else: # O if ent_type is not None: entities.append((ent_type, ent_start, i)) ent_type None if ent_type is not None: entities.append((ent_type, ent_start, len(tag_ids))) return entities说明合并逻辑只有三条规则——遇到 B 开启新实体遇到同类型 I 继续遇到 O 或不同类型的 I 关闭当前实体。BIOES 要多处理 E 和 S 两个分支E 负责收尾S 表示单字实体。源码里如果 decode 走的是logits.argmax而不是crf.decode相当于绕过了维特比非法标签序列会回流到实体合并函数里表现为实体类型乱跳。提示上线时把 decode 分支单独抽成接口并做一次 gold 数据上的回代测试。很多源码项目训练部分很完整decode 部分却写了两个版本一个用于 eval、一个用于线上两边标签映射不一致的事故我见过不止一次。5. 上线前的三个检查实体级评估、切窗策略与源码边界模型训完不等于能上线。最后补三个在真实项目中反复踩到的检查点每个都能单独落地。5.1 不看 token 准确率只看实体级 F1NER 数据里 O 标签通常占 80% 以上token 级准确率即使模型什么都没学会也能到 85%。唯一能反映业务价值的指标是实体级 F1起始位置、结束位置、类型三者完全一致才算一个正确实体。def entity_f1(pred_entities, gold_entities): p {(s, e, t) for s, e, t in pred_entities} g {(s, e, t) for s, e, t in gold_entities} tp len(p g) precision tp / len(p) if p else 0.0 recall tp / len(g) if g else 0.0 f1 2 * precision * recall / (precision recall) if (precision recall) else 0.0 return precision, recall, f1统计时把实体的三元组装成 set 再求交集起止和类型任何一个对不上都不算命中。验证集上同时打印整体实体级指标和每类实体的单独 F1——整体 90 分但 ORG 只有 70 分的模型上线后往往就在组织名上翻车。O 标签占比过高时不要急着调 CRF 的转移矩阵先看稀有类实体的召回率多半是标注样本太少而不是模型结构问题。5.2 长文本切窗实体被拦腰切断的兜底max_len 设成 128 之后超过长度的文本必然要切。粗暴的切法按固定长度硬切实体就可能在窗口边界被切成两半两个窗口各识别出一半。我一般用 75% 重叠的滑窗切分点优先落在标点或换行处再把重复识别的实体按置信度更高的那个去重。如果业务允许按句子切是最省心的方案——句子是语义最小的完整单元跨句实体人名跨句完型属于另一个层面的问题不要靠切窗解决。5.3 和 LLM 大模型方案的分工做意图识别时大家纠结 textcnn、BERT 和 LLM 大模型的取舍这个问题搬到命名实体识别上要换个坐标系。意图识别是句子级任务LLM 的思维链优势能直接发挥NER 是 token 级任务要求输出严格对齐输入、格式稳定、延迟可控。在批量离线抽取、schema 频繁变化的场景让 LLM 大模型零样本抽取确实方便但在高 QPS 在线接口、schema 固定、需要对每个字符负责的场景BERT-BiLSTM-CRF 这套框架依然是性价比最高的选择单条推理在 CPU 上几十毫秒GPU 上可以压到十毫秒以内。两条路线不是替代关系——先用小模型兜底高流量再用 LLM 处理长尾难例是当前团队最常见的分工。源码边界上记住一条模型结构尽量不动只动 config 和数据处理层任何对 BERT-BiLSTM-CRF 结构本身的顺手优化都要先跑一遍实体级 F1 回归确认指标没有倒挂再合入。本文还有配套的精品资源点击获取