BERT+BiLSTM+CRF中文命名实体识别实战:从源码到上线 简介本资源面向计算机、人工智能、数据科学等专业学生及企业开发者提供一套基于BERTBILSTMCRF的中文命名实体识别完整项目源码适合毕业设计、课程设计、大作业及初期项目立项演示也可作为NLP入门实战练习。压缩包共58个文件约13.75MB包含16个Python源码文件、19个编译缓存文件、9个文本数据与说明、4份Markdown文档、5张运行截图及若干配置与数据文件覆盖模型定义、数据预处理、训练脚本与工具模块。项目内置BERT_BILSTM_CRF、BILSTM_CRF、IDCNN_CRF、BILSTM_Attention_CRF等多种网络结构并附带人民日报、MSRA等中文NER数据集及预处理代码便于读者直接复现实验、对比不同模型效果。目前已有1209人学习下载代码经测试可正常运行具有较高的学习借鉴与二次开发价值。1. 从一份 BERTBILSTMCRF 源码包说起中文命名实体识别到底难在哪中文命名实体识别NER是 NLP 落地里最容易被低估的一类任务。很多人第一次接触它是拿到一份「基于 BERTBILSTMCRF 的中文命名实体识别 python 源码项目说明模型数据.zip」解压之后发现里面有bert-base-chinese权重、train.py、model.py、一份标注好的数据集然后卡在第一步环境装不上、标签对不齐、loss 不下降。这份源码包真正值钱的地方不是模型结构本身而是它把「预训练语言模型 序列建模 标签约束」这条工业界最稳的中文 NER 路线完整跑通了一遍。它解决的问题很具体从一段中文文本里抽出人名、地名、机构名、时间、专有名词等实体片段。适合谁适合已经会一点 python、想从「调包跑 demo」进阶到「自己训一个能上线的 NER 模型」的工程师也适合做知识图谱、信息抽取、舆情分析、合同解析、医疗文本结构化的同学。下面我按「原理先立住 → 数据怎么处理 → 模型怎么搭 → 怎么训怎么调 → 坑在哪 → 怎么验证」的顺序把这条路线拆到能照着复现的程度。2. BERTBILSTMCRF 三层结构每一层到底在干什么2.1 为什么是这三层拼在一起而不是只用 BERT先说结论只用 BERT 做 token 分类也能出结果但中文 NER 里实体边界模糊、标签之间存在强约束比如B-PER后面不可能直接跟I-LOC单靠 BERT 的 softmax 逐 token 独立预测容易出现「标签跳变」这种玄学错误。三层结构各司其职BERT 层负责把中文字符/词转成带上下文的向量。中文没有空格BERT 的 WordPiece 会把「北京市」切成「北」「京」「市」或「北京」「市」这一步决定了后面所有信息的质量。BiLSTM 层在 BERT 输出的序列上再跑一次双向循环捕捉相邻标签之间的转移倾向。BERT 本身有注意力但 BiLSTM 对「局部连续实体」的建模更直接尤其在实体较长时能压住抖动。CRF 层把「逐 token 独立打分」变成「整条序列联合打分」用转移矩阵约束非法标签组合。这是 NER 里最不能省的一层去掉 CRF 后 F1 通常会掉 1~3 个点。常见做法是 BERT 输出后接一个 dropout再进 BiLSTM最后接 CRF。也有把 BiLSTM 换成一层线性层的轻量版但既然源码包给了 BiLSTM就按完整版走。2.2 标签体系BIO 还是 BIOES先定死再动手中文 NER 最常用的是 BIO 和 BIOES 两套。BIO 只有 B/I/OBIOES 多了 E实体结尾和 S单字实体。BIOES 对边界更敏感但标注成本高。源码包一般用 BIO因为公开数据集如 MSRA、人民日报、CLUENER大多是 BIO 或可转 BIO。标签含义示例「张三在北京大学」B-PER人名开始张I-PER人名中间三B-LOC地名开始北I-LOC地名中间京/大/学O非实体在动手前必须确认三件事标签列表顺序、label2id映射、CRF 的num_tags是否和标签数一致。这三处任意一处错位训练 loss 会正常下降但预测全乱属于典型「黑匣子」故障。2.3 从零把环境跑起来的最小命令先解决 python 环境。建议用 conda 建独立环境避免和系统 python 冲突conda create -n ner python3.8 -y conda activate ner pip install torch1.13.1 transformers4.28.0 pytorch-crf0.7.2 seqeval1.2.2 pip install numpy pandas tqdm scikit-learn逻辑说明torch和transformers是主干pytorch-crf提供 CRF 层比手写稳定seqeval是 NER 评测标准库能直接算 entity-level 的 precision/recall/F1。参数上python 3.8 是兼容性最好的版本torch 1.13 和 transformers 4.28 搭配在多数显卡驱动上不会翻车。如果显存小于 6G把 batch size 降到 8 或 16并在train.py里打开梯度累积。提示不要用最新版 transformers 直接跑老源码BertTokenizer的do_lower_case和tokenize行为在 4.30 之后有变化容易导致字符和标签错位。3. 数据准备把原始语料转成模型能吃的格式3.1 数据长什么样先做一次肉眼校验源码包里的数据通常是train.txt/dev.txt/test.txt每行是「字符 标签」用空格或制表符分隔句子之间空行。先跑一段脚本统计标签分布和句子长度import collections def load_data(path): sentences, labels [], [] with open(path, encodingutf-8) as f: sent, lab [], [] for line in f: line line.strip() if not line: if sent: sentences.append(sent) labels.append(lab) sent, lab [], [] continue parts line.split() if len(parts) ! 2: continue sent.append(parts[0]) lab.append(parts[1]) return sentences, labels sents, labs load_data(data/train.txt) counter collections.Counter([l for seq in labs for l in seq]) print(标签分布:, counter) print(句子数:, len(sents), 平均长度:, sum(len(s) for s in sents) / len(sents))逻辑说明这段脚本做两件事——按空行切句、统计标签频次。参数上split()不传参能同时兼容空格和制表符。如果发现某个标签数量为 0 或极少说明数据里没有该类实体训练时该标签的 F1 会恒为 0不要误以为模型坏了。3.2 用 BERT tokenizer 对齐字符和标签最容易错的一步中文 BERT 的 tokenizer 会把一个字或几个字合成一个 token还会插入[CLS]、[SEP]。标签必须跟着 token 走常见做法是「只在每个 token 的第一个字上保留标签其余子词标 -100 忽略」from transformers import BertTokenizer tokenizer BertTokenizer.from_pretrained(bert-base-chinese) label_list [O, B-PER, I-PER, B-LOC, I-LOC, B-ORG, I-ORG] label2id {l: i for i, l in enumerate(label_list)} def encode(sent, lab, max_len128): tokens, labels [[CLS]], [-100] for ch, l in zip(sent, lab): sub tokenizer.tokenize(ch) for i, t in enumerate(sub): tokens.append(t) labels.append(label2id[l] if i 0 else -100) tokens.append([SEP]) labels.append(-100) input_ids tokenizer.convert_tokens_to_ids(tokens)[:max_len] labels labels[:max_len] return input_ids, labels逻辑说明-100是 PyTorch CrossEntropyLoss 默认忽略的索引CRF 层里也要同步屏蔽。参数上max_len128覆盖大多数中文句子长文本要截断或滑窗。这里的关键是「子词只保留首标签」否则一个实体被切成多个 token 后标签会重复评测时实体数虚高。3.3 构造 Dataset 和 DataLoaderimport torch from torch.utils.data import Dataset, DataLoader class NERDataset(Dataset): def __init__(self, sents, labs, tokenizer, label2id, max_len128): self.data [encode(s, l, max_len) for s, l in zip(sents, labs)] def __len__(self): return len(self.data) def __getitem__(self, idx): ids, labels self.data[idx] return torch.tensor(ids), torch.tensor(labels) def collate_fn(batch): ids [b[0] for b in batch] labels [b[1] for b in batch] ids torch.nn.utils.rnn.pad_sequence(ids, batch_firstTrue, padding_value0) labels torch.nn.utils.rnn.pad_sequence(labels, batch_firstTrue, padding_value-100) mask ids ! 0 return ids, labels, mask train_loader DataLoader(NERDataset(sents, labs, tokenizer, label2id), batch_size16, shuffleTrue, collate_fncollate_fn)逻辑说明pad_sequence做动态 paddingmask标记真实 token 位置后面 BiLSTM 和 CRF 都要用它屏蔽 padding。参数上padding_value0对应[PAD]batch_size16是 6G 显存的安全值显存大可提到 32。4. 模型搭建与训练BERTBiLSTMCRF 的完整实现4.1 模型定义三层怎么接、维度怎么设import torch.nn as nn from transformers import BertModel from torchcrf import 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) self.dropout nn.Dropout(dropout) self.lstm nn.LSTM(self.bert.config.hidden_size, lstm_hidden, num_layers1, bidirectionalTrue, batch_firstTrue) self.fc nn.Linear(lstm_hidden * 2, num_tags) self.crf CRF(num_tags, batch_firstTrue) def forward(self, input_ids, attention_mask, labelsNone): out self.bert(input_ids, attention_maskattention_mask)[0] out self.dropout(out) out, _ self.lstm(out) emissions self.fc(out) if labels is not None: loss -self.crf(emissions, labels, maskattention_mask.bool(), reductionmean) return loss return self.crf.decode(emissions, maskattention_mask.bool())逻辑说明BERT 输出 768 维BiLSTM 双向各 256 维拼成 512 维再映射到标签数。CRF 的mask必须传否则 padding 位置会参与转移计算。参数上lstm_hidden256是中文 NER 的常用值dropout0.3防止过拟合如果数据量小于 5000 句把 dropout 提到 0.5。4.2 训练循环学习率、优化器、早停from transformers import AdamW, get_linear_schedule_with_warmup device torch.device(cuda if torch.cuda.is_available() else cpu) model BertBiLstmCrf(bert-base-chinese, num_tagslen(label_list)).to(device) optimizer AdamW(model.parameters(), lr2e-5, weight_decay0.01) total_steps len(train_loader) * 10 scheduler get_linear_schedule_with_warmup(optimizer, int(total_steps * 0.1), total_steps) best_f1 0 for epoch in range(10): model.train() for ids, labels, mask in train_loader: ids, labels, mask ids.to(device), labels.to(device), mask.to(device) loss model(ids, mask, labels) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) optimizer.step() scheduler.step() optimizer.zero_grad() # 每个 epoch 后在 dev 上评测保存 best_f1 模型逻辑说明BERT 微调学习率必须小2e-5是经典值太大 loss 会震荡太小收敛慢。warmup占 10% 步数避免初期梯度爆炸。clip_grad_norm_限幅 1.0 是防梯度爆炸的后悔药。参数上weight_decay0.01对 BERT 参数做正则但 bias 和 LayerNorm 通常不 decay严格实现要分组。4.3 评测用 seqeval 算实体级 F1from seqeval.metrics import classification_report, f1_score def evaluate(model, loader, id2label): model.eval() preds, trues [], [] with torch.no_grad(): for ids, labels, mask in loader: ids, mask ids.to(device), mask.to(device) pred model(ids, mask) for p, t, m in zip(pred, labels, mask): length m.sum().item() preds.append([id2label[i] for i in p[:length]]) trues.append([id2label[i] for i in t[:length].tolist() if i ! -100]) print(classification_report(trues, preds)) return f1_score(trues, preds)逻辑说明seqeval按实体整体匹配算分比逐 token 准确率更真实。参数上注意t[:length]里要过滤-100否则标签和预测长度对不齐会报错。如果 F1 在 0.6 以下先查标签对齐再查学习率。5. 避坑与排查中文 NER 训练里最常见的 5 个翻车现场5.1 现象loss 一直不降或直接变 NaN原因通常是学习率过大、CRF 的 mask 没传、或者标签 id 越界。解决先把学习率降到1e-5确认num_tags等于len(label_list)检查 CRF 调用时mask是否为 bool 类型。如果用了混合精度CRF 层对 fp16 支持不好先关掉 amp 再试。5.2 现象训练 F1 很高测试 F1 掉 20 个点这是过拟合或数据泄漏。原因可能是 train 和 dev 有重复句子或者实体类别在测试集里分布完全不同。解决先去重再统计两个集合的标签分布差异大就做分层采样。数据量小于 3000 句时BERT 全量微调很容易过拟合可以只训 BiLSTMCRF冻结 BERT。5.3 现象预测结果里实体边界总是多一个字或少一个字原因在 tokenizer 对齐。中文 BERT 对某些字会合并成子词如果标签只标在首子词解码时要把子词还原成原字符。解决在encode阶段记录每个 token 对应的原字符位置解码后按位置合并。常见做法是维护一个offset_mapping用tokenizer(..., return_offsets_mappingTrue)拿到。5.4 现象显存溢出batch size 降到 1 还爆原因可能是max_len设太大或者 BiLSTM 的 hidden 太大。解决把max_len从 128 降到 64lstm_hidden从 256 降到 128打开梯度累积模拟大 batch。另外torch.no_grad()评测时别忘了否则显存会持续增长。5.5 现象CRF 解码报 “mask 维度不匹配”原因通常是attention_mask的 shape 和 emissions 不一致或者 padding 后 mask 没同步。解决统一用batch_firstTruemask 用ids ! 0生成确保和input_ids同 shape。如果用了pad_sequencepadding 值要和 tokenizer 的pad_token_id一致。6. 进阶技巧把 BERTBiLSTMCRF 用到真实业务里的三个习惯第一个习惯是先做小样本过拟合测试。拿 20 句话训 100 个 epoch如果 F1 能到 0.95 以上说明模型结构和标签对齐没问题再上全量数据。这一步能省掉大量「以为是数据问题其实是代码 bug」的排查时间。第二个习惯是把 CRF 的转移矩阵打印出来看。训练完后model.crf.transitions是一个num_tags x num_tags的矩阵正常情况B-PER - I-PER的分数应该明显高于B-PER - I-LOC。如果矩阵接近均匀说明 CRF 没学到东西检查学习率或标签数量。第三个习惯是用真实业务文本做 badcase 分析。公开数据集 F1 到 0.9 不代表业务可用因为业务文本里会有大量未登录词、缩写、中英混排。我一般会抽 100 条线上文本人工标一遍看模型错在哪是边界错、类别错还是漏实体。边界错就调 tokenizer 对齐类别错就补标注数据漏实体就检查O标签是否过多导致模型偏向不预测。最后一个具体技巧如果业务对推理速度敏感可以把 BiLSTM 换成一层nn.LinearF1 通常只掉 0.5~1 个点但推理快 30% 以上。这个取舍我在实际项目里做过多次数据量越大BiLSTM 的收益越小。希望帮到你。本文还有配套的精品资源点击获取