尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

中文命名实体识别边界不准?BERT+BILSTM+CRF 从零实战

发布时间:2026/9/26 12:13:23

资讯中心
01
ARTICLE

中文命名实体识别边界不准?BERT+BILSTM+CRF 从零实战

中文命名实体识别边界不准?BERT+BILSTM+CRF 从零实战
简介这份资源是面向计算机相关专业学生与项目实战学习者的中文命名实体识别完整项目采用BERTBILSTMCRF经典组合实现可作为课程设计、期末大作业或NLP入门练手参考。项目由大三学生完成并经导师指导认可评审得分99分代码完整可运行对基础薄弱者同样友好。压缩包共15个文件以9个py源码文件为核心搭配4个txt配置与说明、1个md项目文档及gitignore整体约406KB结构紧凑、便于快速上手。内容涵盖数据加载、模型定义、训练与预测等模块并附项目说明与模型文件读者可据此理解BERT预训练表示、双向LSTM上下文建模与CRF序列标注的协作流程掌握从数据预处理到实体识别的完整链路。目前已有90人学习适合需要一份可复现、有评审背书的NLP大作业参考的读者。1. 中文命名实体识别为什么总在「边界」上翻车做中文 NLP 的同行大概率都遇到过这种场景用 BERT 微调一个序列标注模型实体类型识别得七七八八但一到实体边界就开始玄学——「北京大学第三医院」被切成「北京大学」和「第三医院」两个 ORG「2023 年 5 月」的日期只标出「2023 年」。这不是模型不够大而是中文命名实体识别NER的边界判定天然依赖标签之间的转移约束而单纯的 BERTSoftmax 逐 token 独立分类恰恰丢掉了这层约束。BERTBILSTMCRF 这套组合就是冲着这个问题来的BERT 负责把字/词映射成带上下文语义的向量BILSTM 进一步捕捉长距离双向依赖CRF 层则在输出端强制标签序列的合法性比如 I-ORG 不能直接跟在 B-PER 后面。三者叠加是中文 NER 里最经典、也最容易复现的工程方案。这篇文章面向想用 Python 从零跑通这套模型的人——不管你是刚配好 vscode python 环境的新手还是想换掉线上规则系统的老手都能照着把数据、模型、训练、推理这条链路走完并且知道每一步的参数为什么这么设、坑在哪。2. BERTBILSTMCRF 的选型逻辑与数据准备2.1 为什么是这三层而不是只用 BERT先说清楚每一层解决什么问题选型才不是拍脑袋。BERT 的输出是每个 token 的上下文向量但它本质是个特征提取器做序列标注时通常直接接一个线性层 Softmax对每个位置独立预测标签。这种「逐点分类」的致命伤是它不知道标签之间的转移是否合法。中文 NER 用 BIO 标注体系时合法序列有硬约束——I-X 前面必须是 B-X 或 I-XO 后面不能直接接 I-X。逐点分类会产出大量非法序列边界自然乱。BILSTM 加在 BERT 之上作用是再叠一层双向序列建模。有人会问BERT 本身不就是双向的吗是的但 BERT 的注意力是全局的BILSTM 提供的是局部邻域内的顺序归纳偏置对实体边界的连续性有额外增益尤其在实体较长、跨词边界时更明显。实践中加 BILSTM 通常能带来 0.5~1.5 个点的 F1 提升代价是训练慢一些。CRF 是最后一道保险。它把标签序列当成一个整体来打分学习一个转移矩阵解码时用 Viterbi 算法找全局最优路径。这样非法转移的概率会被压到极低边界一致性显著改善。这三层的分工可以概括成BERT 管「这个词是什么」BILSTM 管「前后文怎么连」CRF 管「标签怎么排才合法」。2.2 数据格式与标注体系BIO 还是 BMES中文 NER 最常用两种标注体系体系标签示例适用场景边界精度BIOB-PER, I-PER, O通用实体较短中等BMESB-PER, M-PER, E-PER, S-PER实体较长、需精确边界较高BIO 简单但单个字的实体只能标成 B-X无法区分「单字实体」和「多字实体开头」。BMES 用 B开始、M中间、E结束、S单字四类边界信息更完整代价是标签数翻倍、数据量要求更高。我一般建议实体平均长度小于 3 字用 BIO大于 3 字或对边界要求苛刻如医疗、法律用 BMES。数据文件通常是一行一个字加标签空行分隔句子形如北 B-ORG 京 I-ORG 大 I-ORG 学 I-ORG 位 O 于 O 海 O 淀 O2.3 用 Python 把原始语料转成模型输入假设你手里是「句子\t实体列表」的原始格式需要转成上面的逐字标注。下面这段脚本做三件事按实体跨度打标签、处理重叠实体、输出 BIO 格式。# convert_to_bio.py # 将 句子\t实体1:类型1,实体2:类型2 转为逐字 BIO 标注 import re def char_tokenize(sentence): 中文按字切分保留原始字符顺序 return list(sentence) def span_to_bio(sentence, entities): sentence: 原始句子字符串 entities: [(start, end, label), ...] start/end 为字符索引左闭右开 返回: [(char, tag), ...] chars char_tokenize(sentence) tags [O] * len(chars) # 按起始位置排序先处理靠前的实体 for start, end, label in sorted(entities, keylambda x: x[0]): if start 0 or end len(chars) or start end: continue # 越界实体直接丢弃避免污染训练集 # 检查是否与已标注区域重叠 if any(tags[i] ! O for i in range(start, end)): continue # 重叠实体跳过中文 NER 一般不处理嵌套 tags[start] fB-{label} for i in range(start 1, end): tags[i] fI-{label} return list(zip(chars, tags)) def write_bio_file(samples, out_path): samples: [(sentence, entities), ...] with open(out_path, w, encodingutf-8) as f: for sent, ents in samples: for ch, tag in span_to_bio(sent, ents): f.write(f{ch} {tag}\n) f.write(\n) # 空行分隔句子 if __name__ __main__: demo [(北京大学位于海淀区, [(0, 4, ORG), (6, 9, LOC)])] write_bio_file(demo, train.bio)逻辑说明span_to_bio先给整句打上 O再按实体跨度覆盖成 B/I。重叠检测那一步很关键——中文语料里经常出现「北京大学」和「北京」同时被标为实体的情况如果不跳过后写的标签会覆盖前面的导致标注错乱。参数上start/end用字符索引而非字节索引中文一个字算一个位置别用len(sentence.encode())去算那是字节数会错位。提示转换完一定要抽样人工核对 20~30 句尤其是含数字、英文、标点的句子分词和索引最容易在这里翻车。3. 模型搭建BERT 输出怎么接 BILSTM 再接 CRF3.1 三个模块的接口对齐搭模型最容易出错的地方不是某一层写错而是张量形状对不上。先把数据流理清楚BERT 输入input_ids形状[batch, seq_len]输出last_hidden_state形状[batch, seq_len, hidden]其中 hidden 对 base 模型是 768。BILSTM 接收[batch, seq_len, hidden]输出[batch, seq_len, 2*lstm_hidden]。注意 BILSTM 默认batch_firstFalse必须显式设batch_firstTrue否则维度和 BERT 对不上这是新手最常见的翻车点。线性层把2*lstm_hidden映射到num_tags得到发射分数emissions形状[batch, seq_len, num_tags]。CRF 层接收emissions和tags训练时算负对数似然推理时用 Viterbi 解码。3.2 完整模型代码与关键参数# model.py import torch import torch.nn as nn from transformers import BertModel from torchcrf import CRF # pip install pytorch-crf class BertBiLstmCrf(nn.Module): def __init__(self, bert_path, num_tags, lstm_hidden256, dropout0.3): super().__init__() self.bert BertModel.from_pretrained(bert_path) hidden self.bert.config.hidden_size # base 为 768 self.bilstm nn.LSTM( input_sizehidden, hidden_sizelstm_hidden, num_layers1, batch_firstTrue, # 必须为 True与 BERT 输出对齐 bidirectionalTrue ) self.dropout nn.Dropout(dropout) # 双向输出拼接后维度为 2*lstm_hidden self.classifier nn.Linear(lstm_hidden * 2, num_tags) self.crf CRF(num_tags, batch_firstTrue) def forward(self, input_ids, attention_mask, tagsNone): outputs self.bert(input_idsinput_ids, attention_maskattention_mask) seq_out outputs.last_hidden_state # [B, L, 768] seq_out, _ self.bilstm(seq_out) # [B, L, 2*H] seq_out self.dropout(seq_out) emissions self.classifier(seq_out) # [B, L, num_tags] # CRF 需要 mask 屏蔽 padding 位置 mask attention_mask.bool() if tags is not None: # 训练返回负对数似然取负号做 loss loss -self.crf(emissions, tags, maskmask, reductionmean) return loss else: # 推理Viterbi 解码返回每条序列的标签列表 return self.crf.decode(emissions, maskmask)逻辑说明batch_firstTrue在 LSTM 和 CRF 上都要设CRF 的batch_first参数决定它按[B, L, T]还是[L, B, T]解释输入和 LSTM 不一致会直接报维度错误。mask用attention_mask转 bool作用是让 CRF 在算转移分数和解码时忽略 padding 位置——如果不传 maskpadding 的标签会参与转移计算模型会学到一堆无意义的转移验证集 F1 会莫名偏低。参数说明lstm_hidden256是经验值双向拼接后 512再映射到标签数显存紧张可以降到 128。dropout0.3在 BERT 微调场景下比较稳太高会欠拟合太低容易过拟合小数据集。num_layers1足够堆到 2 层收益很小还容易梯度问题。3.3 标签对齐与 loss 计算的两个细节第一个细节tags里 padding 位置要填一个合法标签索引通常填 0 对应的 O不能填 -100。因为 CRF 的 loss 计算依赖完整标签序列-100 会让torchcrf报索引越界。正确做法是在 collate 阶段把 padding 的标签设成 O 的 id同时靠 mask 屏蔽掉。第二个细节reductionmean是对 batch 内所有有效 token 求平均如果你的 batch 里句子长度差异极大建议改成sum再手动除以有效 token 数避免短句被长句稀释。这个改动对 F1 的影响在小数据集上能到 1 个点以上。4. 训练、评估与推理的完整链路4.1 数据集类与动态 padding中文句子长度差异大固定 padding 到 128 会浪费大量算力。用collate_fn做 batch 内动态 padding# dataset.py import torch from torch.utils.data import Dataset class NerDataset(Dataset): def __init__(self, samples, tokenizer, tag2id, max_len128): self.samples samples # [(chars, tags), ...] self.tokenizer tokenizer self.tag2id tag2id self.max_len max_len def __len__(self): return len(self.samples) def __getitem__(self, idx): chars, tags self.samples[idx] # 中文按字输入is_split_into_wordsTrue 让 BERT 按字处理 enc self.tokenizer( chars, is_split_into_wordsTrue, truncationTrue, max_lengthself.max_len, paddingFalse, return_tensorsNone ) label_ids [self.tag2id[t] for t in tags][:self.max_len] return { input_ids: enc[input_ids], attention_mask: enc[attention_mask], labels: label_ids } def collate_fn(batch): max_len max(len(x[input_ids]) for x in batch) input_ids, attn, labels [], [], [] for x in batch: pad max_len - len(x[input_ids]) input_ids.append(x[input_ids] [0] * pad) attn.append(x[attention_mask] [0] * pad) # padding 标签填 O 的 id假设 O 的 id 为 0靠 mask 屏蔽 labels.append(x[labels] [0] * pad) return { input_ids: torch.tensor(input_ids, dtypetorch.long), attention_mask: torch.tensor(attn, dtypetorch.long), labels: torch.tensor(labels, dtypetorch.long) }逻辑说明is_split_into_wordsTrue告诉 tokenizer 输入已经是切好的字序列不要再做子词切分。中文 BERT 的 vocab 基本按字覆盖但遇到生僻字或英文数字仍可能被拆成多个 subword这时labels长度会和input_ids对不上。稳妥做法是用word_ids()做对齐把同一 word 的多个 subword 只在第一个位置保留标签其余设成 -100 并在 loss 里忽略。上面代码为简洁假设了字级对齐实际生产务必加这层校验。4.2 训练循环与学习率设置# train.py import torch from torch.optim import AdamW from transformers import BertTokenizer, get_linear_schedule_with_warmup def train(model, loader, epochs10, lr2e-5, devicecuda): model.to(device) # BERT 用较小学习率LSTM 和分类头可以用大一点 bert_params list(model.bert.named_parameters()) other_params [p for n, p in model.named_parameters() if not n.startswith(bert)] optimizer AdamW([ {params: [p for _, p in bert_params], lr: lr}, {params: other_params, lr: lr * 10} ], weight_decay0.01) total_steps len(loader) * epochs scheduler get_linear_schedule_with_warmup( optimizer, num_warmup_stepsint(total_steps * 0.1), num_training_stepstotal_steps ) model.train() for epoch in range(epochs): total_loss 0 for batch in loader: batch {k: v.to(device) for k, v in batch.items()} loss model(batch[input_ids], batch[attention_mask], batch[labels]) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) optimizer.step() scheduler.step() optimizer.zero_grad() total_loss loss.item() print(fepoch {epoch1} loss {total_loss/len(loader):.4f})逻辑说明BERT 和随机初始化的 LSTM/分类头对学习率的敏感度不同前者用 2e-5后者用 2e-4即 lr*10这是微调的标准做法。clip_grad_norm_设 1.0 防止 CRF 层梯度爆炸中文 NER 训练里梯度爆炸不算罕见尤其 batch 小的时候。warmup 比例 0.1 是经验值能避免训练初期 BERT 被大梯度带偏。4.3 用 seqeval 算实体级 F1token 级准确率会骗人——把所有字都预测成 O准确率也能到 90% 以上。必须用实体级指标# evaluate.py from seqeval.metrics import classification_report, f1_score def evaluate(model, loader, id2tag, devicecuda): model.eval() preds, trues [], [] with torch.no_grad(): for batch in loader: batch {k: v.to(device) for k, v in batch.items()} pred_tags model(batch[input_ids], batch[attention_mask]) for p, t, m in zip(pred_tags, batch[labels].tolist(), batch[attention_mask].tolist()): length sum(m) preds.append([id2tag[i] for i in p[:length]]) trues.append([id2tag[i] for i in t[:length]]) print(classification_report(trues, preds)) return f1_score(trues, preds)逻辑说明seqeval按实体整体匹配计算 P/R/F1B-ORG 和 I-ORG 必须连续且类型一致才算命中。length sum(m)用 mask 截掉 padding否则 padding 的 O 会被算进指标虚高。注意pred_tags来自crf.decode返回的是 list of list长度已经是有效长度但为保险还是按 mask 截一次。5. 避坑与排查中文 NER 训练里最常见的 5 个翻车现场5.1 现象loss 不降一直卡在 8 左右原因标签 id 和模型输出维度不匹配或者 padding 标签填了 -100 导致 CRF 计算异常。中文 NER 标签数通常是 2*实体类型数1BIO如果num_tags设错CRF 转移矩阵维度对不上loss 会卡在一个固定值。解决打印tag2id确认标签总数检查CRF(num_tags)的入参。padding 标签统一填 O 的 id靠 mask 屏蔽不要用 -100。5.2 现象验证集 F1 比训练集低 20 个点原因数据泄漏或标注不一致。常见的是同一实体在不同句子里标了不同类型或者训练集和验证集有重复句子。中文语料从网页爬下来时模板句重复率很高。解决训练前用句子哈希去重跨集合检查重复。标注一致性用脚本扫一遍统计每个实体字符串对应的标签类型出现多类型的挑出来人工复核。5.3 现象推理时实体边界总是少一个字原因BILSTM 的batch_first没设成 True或者 CRF 的batch_first和 LSTM 不一致导致序列顺序错乱模型学到的是错位的转移模式。解决两处都显式设batch_firstTrue并在 forward 里打印一次emissions.shape确认是[B, L, num_tags]。顺序错了不会报错只会静默地让指标变差这是最阴的坑。5.4 现象显存溢出batch 只能开到 4原因BERT base 加 BILSTM 后参数量上去了加上 CRF 的转移矩阵和 Viterbi 解码的中间张量显存吃紧。seq_len 设 128 时batch 16 在 8G 卡上基本跑不动。解决开梯度累积模拟大 batchaccum_steps4配合 batch 4 等效 batch 16开混合精度torch.cuda.amp显存能省 30% 左右seq_len 按数据实际长度分布设别盲目 128统计一下 95 分位数。5.5 现象换用自己领域的数据后 F1 暴跌原因BERT 预训练语料和领域语料分布差异大直接微调小数据容易灾难性遗忘。医疗、法律、金融领域的实体形态和通用语料差别明显。解决先用领域语料做继续预训练MLM 任务再微调 NER或者冻结 BERT 前几层只训后几层加 BILSTMCRF。数据量小于 2000 句时冻结底层往往比全量微调更稳。6. 把模型压到能上线的三个进阶技巧跑通训练只是第一步真正要落地还得解决推理速度和部署问题。分享三个我实际用过的技巧。技巧一BERT 层用 ONNX 导出加速。PyTorch 原生推理在 CPU 上很慢把 BERT 部分导出成 ONNX用 onnxruntime 跑CPU 推理能快 2~3 倍。BILSTM 和 CRF 部分保留 PyTorch因为 CRF 的 Viterbi 解码用 numpy 手写反而更快。导出时注意固定input_ids和attention_mask的动态轴否则 batch 变化会报错。技巧二CRF 解码用 batch Viterbi。torchcrf的 decode 是逐条循环的batch 大了很慢。可以自己实现向量化的 Viterbi把转移矩阵和发射分数用广播做batch 32 时解码耗时能降一半。核心是把[B, L, T]的发射分数和[T, T]的转移矩阵做外积逐步取 max 并记录回溯指针。技巧三小模型蒸馏。如果线上 QPS 要求高用 BERT-base 的模型蒸馏到 4 层 BERT 或直接用 BiLSTMCRF不接 BERTF1 通常掉 3~5 个点但推理速度快 5 倍以上。蒸馏时用大模型的软标签发射分数做监督比硬标签效果好。验证方法上我习惯在测试集上按实体类型分别看 F1而不是只看总体。中文 NER 里 PER 通常最高ORG 和 LOC 因为边界模糊往往低 5~10 个点。如果某个类型特别低先查标注质量再考虑加类型特征或调整该类型的 loss 权重。最后说个血泪教训别在没做数据去重和标注一致性检查的情况下就调模型结构。我早期有次 F1 死活上不去换了三种模型都没用最后发现是训练集里 15% 的句子和验证集重复模型在背答案。数据干净比模型花哨重要得多。希望帮到你。本文还有配套的精品资源点击获取
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

◈

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

◐

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

▲

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。