资讯动态

中文NER边界识别难题:BERT-BiLSTM-CRF实战与优化

发布时间:2026/10/18 15:33:08 来源:尧图企业网站定制
简介本资源面向计算机、人工智能、通信工程等专业的在校学生与教师以及需要完成毕业设计、课程设计或大作业的开发者提供一套基于BERT-BiLSTM-CRF的中文命名实体识别完整项目。包内共20个文件以6个Python源码、8个JSON配置、5个文本数据及1份Markdown说明为主压缩包约1.03MB涵盖模型定义、数据处理、训练测试与预测全流程。项目以dgre数据为例包含原始数据与BIO格式标注数据、标签文件、预训练模型配置及模型保存目录并给出依赖版本与参数调整说明便于读者快速复现训练与预测。已有1252人学习下载适合作为入门NLP序列标注的实践参考也可在此基础上修改以适配其他实体识别任务。1. 中文 NER 为什么总在「边界」上翻车从 BERT-BiLSTM-CRF 说起做中文命名实体识别NER的工程师大概率都经历过这种场景模型在测试集上 F1 刷到 0.95一上真实业务文本人名、机构名、地名的边界就开始飘——「北京大学」被切成「北京」和「大学」两个实体「张三丰」只识别出「张三」。这不是模型不够大而是序列标注任务里标签之间的转移约束没有被显式建模。BERT-BiLSTM-CRF 这套组合正是为了解决这个问题而成为中文 NER 的经典基线BERT 负责把字变成带上下文的向量BiLSTM 负责捕捉长距离依赖CRF 负责在输出层保证标签序列的合法性比如 I-ORG 不能出现在 B-ORG 之前。这套方案适合谁如果你手头有一批标注好的中文文本几千到几万条想快速搭一个能落地的 NER 服务或者想理解序列标注的完整工程链路那这份「python源码项目使用说明数据模型」的打包内容就是一条省去重复造轮子的路径。它不追求 SOTA但胜在结构清晰、可复现、可替换骨干网络。接下来我会按「数据怎么进、模型怎么搭、训练怎么调、坑怎么避」的顺序把这条链路拆开讲透。2. 数据准备与标注格式把原始语料喂进 BERT-BiLSTM-CRF 之前2.1 中文 NER 的标注体系选型BIO 还是 BMES在动手写模型之前先确定标签体系。中文 NER 最常见的是 BIO 和 BMES 两套。BIO 用 B-X、I-X、O 三个标签简单直接BMES 把实体拆成 Begin、Middle、End、Single 四种位置对单字实体和长实体的边界更敏感。我一般推荐 BMES因为中文里单字实体如姓氏「张」和长实体如「某某省某某市某某区」并存BMES 能减少边界歧义。标签体系标签示例适用场景缺点BIOB-PER, I-PER, O实体边界清晰、长度均匀单字实体易与 O 混淆BMESB-PER, M-PER, E-PER, S-PER中文单字/长实体混合标签数翻倍需更多数据选好体系后数据格式建议统一成「字 标签」两列句子之间用空行分隔。这样后续转成 BERT 的输入特征时只需要按字对齐即可。2.2 从原始文本到 BERT 输入对齐、截断与标签映射BERT 的 tokenizer 对中文是按字切分但会插入 [CLS]、[SEP] 以及可能的 [UNK]。这里有个血泪经验标签序列必须和 token 序列严格对齐否则训练时 loss 会莫名其妙不下降。常见做法是先用 tokenizer 把句子转成 token ids再根据每个 token 对应的原始字位置把标签映射过去。对于 [CLS] 和 [SEP]标签设为 -100PyTorch 的 ignore_index不参与 loss 计算。from transformers import BertTokenizer import torch tokenizer BertTokenizer.from_pretrained(bert-base-chinese) label2id {O: 0, B-PER: 1, I-PER: 2, B-ORG: 3, I-ORG: 4, B-LOC: 5, I-LOC: 6} def convert_to_bert_features(text, labels, max_len128): # text: 字符串labels: 与字对齐的标签列表 tokens tokenizer.tokenize(text) input_ids tokenizer.convert_tokens_to_ids([[CLS]] tokens [[SEP]]) # 标签对齐CLS 和 SEP 用 -100 忽略 label_ids [-100] [label2id[l] for l in labels] [-100] # 截断与填充 if len(input_ids) max_len: input_ids input_ids[:max_len] label_ids label_ids[:max_len] attention_mask [1] * len(input_ids) padding_len max_len - len(input_ids) input_ids [0] * padding_len attention_mask [0] * padding_len label_ids [-100] * padding_len return { input_ids: torch.tensor(input_ids).unsqueeze(0), attention_mask: torch.tensor(attention_mask).unsqueeze(0), labels: torch.tensor(label_ids).unsqueeze(0) }这段代码的关键点有三个一是[CLS]和[SEP]的标签必须设为 -100否则模型会去预测这些位置干扰实体边界学习二是max_len要根据你的文本长度分布来定中文 NER 一般 128 够用长文本可以设 256 但显存会涨三是attention_mask要正确标记 padding 位置否则 BiLSTM 会把填充符当成真实字处理。2.3 数据划分与类别不平衡处理拿到标注数据后按 8:1:1 切分训练、验证、测试集。注意切分时要保证实体类别分布一致否则验证集 F1 会虚高。如果某些实体类别样本极少比如只有几十个可以在 loss 里给这些类别加权或者用 focal loss 替代交叉熵。我一般会先统计每个类别的样本数对少于 100 的类别权重设为 2.0 到 5.0 之间。3. 模型搭建BERT-BiLSTM-CRF 的三段式结构与参数配置3.1 BERT 输出怎么接 BiLSTM维度、dropout 与 batch_firstBERT 的输出是[batch_size, seq_len, hidden_size]中文 base 模型 hidden_size 是 768。BiLSTM 的输入维度就是 768隐藏层维度一般设 128 或 256双向拼接后输出维度是 hidden_size * 2。这里有个容易翻车的点PyTorch 的 LSTM 默认batch_firstFalse输入格式是[seq_len, batch_size, hidden_size]所以要么在传入前 transpose要么初始化时设batch_firstTrue。我习惯设batch_firstTrue少一次转置少一次出错。import torch.nn as nn from transformers import BertModel class BertBiLSTMCRF(nn.Module): def __init__(self, bert_path, num_labels, lstm_hidden256, dropout0.3): super().__init__() self.bert BertModel.from_pretrained(bert_path) self.dropout nn.Dropout(dropout) self.bilstm nn.LSTM( input_size768, hidden_sizelstm_hidden, num_layers1, bidirectionalTrue, batch_firstTrue ) self.classifier nn.Linear(lstm_hidden * 2, num_labels) self.crf CRF(num_labels, batch_firstTrue) # 假设使用 pytorch-crf def forward(self, input_ids, attention_mask, labelsNone): outputs self.bert(input_ids, attention_maskattention_mask) sequence_output outputs[0] # [batch, seq_len, 768] sequence_output self.dropout(sequence_output) lstm_output, _ self.bilstm(sequence_output) # [batch, seq_len, 512] emissions self.classifier(lstm_output) # [batch, seq_len, num_labels] if labels is not None: loss -self.crf(emissions, labels, maskattention_mask.bool()) return loss else: return self.crf.decode(emissions, maskattention_mask.bool())参数说明lstm_hidden设 256 是经验值再大容易过拟合再小欠拟合dropout设 0.3 到 0.5BERT 微调时 dropout 太低会导致验证集波动大num_layers1足够中文 NER 任务两层 BiLSTM 收益不明显但显存翻倍。3.2 CRF 层的作用与转移矩阵初始化CRF 的核心是一个[num_labels, num_labels]的转移矩阵学习标签之间的合法转移。比如 B-PER 后面跟 I-PER 是合法的跟 I-ORG 是非法的CRF 会在解码时惩罚非法路径。初始化时转移矩阵一般用随机小值训练中自动学习。注意CRF 的 loss 计算需要 mask把 padding 位置排除掉否则 padding 的标签会污染转移矩阵。3.3 学习率与优化器BERT 和 BiLSTM 要分开设这是新手最容易踩的坑BERT 已经预训练过学习率要小2e-5 到 5e-5BiLSTM 和 CRF 是随机初始化学习率要大1e-3 到 1e-2。如果统一用一个学习率要么 BERT 被训崩要么 BiLSTM 学不动。常见做法是分组参数BERT 参数用 2e-5其他层用 1e-3优化器用 AdamW权重衰减设 0.01。from torch.optim import AdamW bert_params list(model.bert.named_parameters()) other_params list(model.bilstm.named_parameters()) list(model.classifier.named_parameters()) list(model.crf.named_parameters()) optimizer AdamW([ {params: [p for n, p in bert_params], lr: 2e-5}, {params: [p for n, p in other_params], lr: 1e-3} ], weight_decay0.01)4. 训练与推理让模型真正跑起来的命令与参数4.1 训练脚本的核心循环与验证指标训练循环里每个 epoch 跑完训练集后在验证集上算 F1。注意验证时要关掉 dropout用model.eval()否则 F1 会偏低。F1 的计算用 seqeval 库它能正确处理实体级别的匹配而不是标签级别的准确率。from seqeval.metrics import f1_score, classification_report def evaluate(model, dataloader, id2label): model.eval() preds, trues [], [] with torch.no_grad(): for batch in dataloader: input_ids batch[input_ids].cuda() attention_mask batch[attention_mask].cuda() labels batch[labels].cuda() pred_labels model(input_ids, attention_mask) # 把 -100 和 padding 去掉转成标签字符串 for pred, true in zip(pred_labels, labels): pred_seq [id2label[p] for p in pred if p ! -100] true_seq [id2label[t.item()] for t in true if t ! -100] preds.append(pred_seq) trues.append(true_seq) return f1_score(trues, preds)参数说明batch_size设 16 或 32取决于显存epochs一般 10 到 20看验证集 F1 什么时候不再涨max_grad_norm设 1.0 做梯度裁剪防止 BERT 微调时梯度爆炸。4.2 推理阶段单条文本预测与批量预测的差异推理时单条文本和批量文本的处理逻辑不同。单条文本直接 tokenize 后加 batch 维度即可批量文本要 pad 到同一长度并且记录每条的真实长度解码时按真实长度截断。这里有个玄学问题批量推理时如果 padding 太多CRF 解码可能受干扰所以尽量按长度分桶减少 padding 比例。def predict_single(text, model, tokenizer, id2label, max_len128): model.eval() tokens tokenizer.tokenize(text) input_ids tokenizer.convert_tokens_to_ids([[CLS]] tokens [[SEP]]) if len(input_ids) max_len: input_ids input_ids[:max_len] attention_mask [1] * len(input_ids) padding_len max_len - len(input_ids) input_ids [0] * padding_len attention_mask [0] * padding_len input_ids torch.tensor(input_ids).unsqueeze(0).cuda() attention_mask torch.tensor(attention_mask).unsqueeze(0).cuda() with torch.no_grad(): pred model(input_ids, attention_mask)[0] # 去掉 CLS 和 SEP 对应的位置 pred pred[1:len(tokens)1] entities [] current [] for token, label in zip(tokens, pred): if label.startswith(B-): if current: entities.append((.join(current[0]), current[1])) current [[token], label[2:]] elif label.startswith(I-) and current: current[0].append(token) else: if current: entities.append((.join(current[0]), current[1])) current [] if current: entities.append((.join(current[0]), current[1])) return entities这段代码的逻辑是按标签的 B/I 前缀合并字遇到 B 就开新实体遇到 I 就追加遇到 O 就收尾。注意pred[1:len(tokens)1]这一步是把 [CLS] 和 [SEP] 的预测结果去掉只保留真实字的标签。4.3 模型保存与加载state_dict 的坑保存模型时建议只保存state_dict不保存整个模型对象否则加载时依赖的类定义必须完全一致。加载时先实例化模型结构再load_state_dict。如果用了 DataParallel 或 DistributedDataParallel保存时要加.module前缀加载时要去掉。# 保存 torch.save(model.state_dict(), ner_model.pt) # 加载 model BertBiLSTMCRF(bert_pathbert-base-chinese, num_labels7) model.load_state_dict(torch.load(ner_model.pt)) model.cuda()5. 避坑与排查中文 NER 训练中常见的 5 个翻车现场5.1 现象loss 不下降F1 始终在 0.1 左右原因标签对齐错了。最常见的是 tokenizer 把某些字切成了 [UNK]但标签还按原字对齐导致标签和 token 错位。解决在 tokenize 后打印 token 和标签的对应关系确认每个 token 都有正确的标签。如果出现 [UNK]要么换 tokenizer要么把 [UNK] 的标签设为 -100。5.2 现象验证集 F1 比训练集高很多原因验证集太小或者分布和训练集不一致。比如验证集里只有人名没有机构名模型只要学会预测人名就能拿高分。解决重新切分数据保证每个类别的样本在三个集合里比例一致。可以用 sklearn 的train_test_split加stratify参数但要注意 NER 的标签是序列stratify 要按实体类别做。5.3 现象推理时实体边界多一个字或少一个字原因CRF 的转移矩阵学偏了或者 BiLSTM 的 hidden_size 太小长距离依赖没捕捉到。解决先把 BiLSTM 的 hidden_size 从 128 调到 256如果还不行检查 CRF 的 mask 是否正确。另外BMES 标签体系比 BIO 更能缓解边界问题可以试试切换。5.4 现象训练到第 3 个 epoch 后 F1 突然掉下去原因BERT 的学习率太大把预训练权重训崩了。解决把 BERT 的学习率从 5e-5 降到 2e-5或者加 warmup前 10% 的 step 学习率从 0 线性升到设定值。另外梯度裁剪max_grad_norm1.0一定要加。5.5 现象显存不够batch_size 只能设 4原因max_len 设太大或者 BiLSTM 的 hidden_size 太大。解决先把 max_len 从 256 降到 128如果还不够把 BiLSTM 的 hidden_size 从 256 降到 128或者用梯度累积模拟大 batch。梯度累积的写法是每跑几步才optimizer.step()一次。6. 进阶技巧用对抗训练和模型融合把 F1 再提两个点如果你已经跑通了基线F1 卡在 0.90 左右上不去可以试试两个进阶手段。第一个是FGM 对抗训练在 embedding 层加扰动让模型对输入变化更鲁棒。实现很简单在每次 forward 后对 embedding 的梯度做归一化加到原 embedding 上再算一次 loss反向传播两次。第二个是多模型融合用不同的随机种子训 3 到 5 个 BERT-BiLSTM-CRF推理时对 emission 取平均再送进 CRF 解码。这两个手段我都在实际项目里用过FGM 一般能提 0.5 到 1 个点融合能提 1 到 2 个点但推理成本会线性增长。# FGM 对抗训练核心代码 class FGM: def __init__(self, model): self.model model self.backup {} def attack(self, epsilon1.0): for name, param in self.model.named_parameters(): if param.requires_grad and embedding in name: self.backup[name] param.data.clone() norm torch.norm(param.grad) if norm ! 0: r_at epsilon * param.grad / norm param.data.add_(r_at) def restore(self): for name, param in self.model.named_parameters(): if name in self.backup: param.data self.backup[name] self.backup {}用法是在训练循环里先正常 forward 和 backward然后fgm.attack()再 forward 和 backward 一次最后fgm.restore()和optimizer.step()。注意 epsilon 一般设 0.5 到 1.0太大反而掉点。最后说个我自己的习惯每次改完模型结构或参数先在一个小数据集比如 500 条上跑 2 个 epoch看 loss 能不能降到 0.5 以下能降再上全量数据。这样能省下大量等训练的时间也能快速定位是数据问题还是模型问题。希望帮到你。本文还有配套的精品资源点击获取

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

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

免费获取报价 →
↑