简介这份资源面向计算机、人工智能、数据科学等专业的学生与开发者提供一套完整的中文命名实体识别实战方案采用BERTBILSTMCRF经典组合可用于课程设计、毕业设计或初期项目立项演示。压缩包共58个文件约13.75MB包含16个Python源码文件、19个编译缓存文件、9个文本数据与说明、4份Markdown文档、5张效果图及若干配置与数据文件覆盖模型定义、数据预处理、训练脚本与工具模块。项目内置BERT_BILSTM_CRF、BILSTM_CRF、IDCNN_CRF等多种网络实现并附带人民日报、MSRA等中文NER数据集及预处理代码方便直接复现实验。目前已有1210人学习下载适合希望快速理解序列标注流程、掌握BERT微调与CRF解码细节的读者参考借鉴。1. 中文命名实体识别BERTBILSTMCRF 到底解决了什么业务问题做中文 NLP 的工程师大概率都遇到过这样的场景一批用户评论、合同文本或者医疗病历丢过来老板要你从中把「人名、地名、机构名、时间、药品、疾病」这些关键信息抽出来做成结构化字段入库。正则写了几十条规则越堆越厚遇到「张三丰在北京市海淀区成立了一家叫字节跳动的公司」这种句子规则直接崩盘——「张三丰」是人名还是地名「北京市海淀区」要不要拆成两级「字节跳动」后面跟「公司」才算机构吗这就是中文命名实体识别NER要解决的核心问题。标题里的 BERTBILSTMCRF 是目前中文 NER 最经典、落地最稳的一套组合方案。BERT 负责把字变成带上下文语义的向量BILSTM 负责捕捉序列前后依赖CRF 负责保证输出的标签序列合法比如 I-PER 不能出现在 B-PER 前面。这套方案在 MSRA、人民日报、Weibo 等公开数据集上 F1 普遍能到 90% 以上工业界大量抽取任务至今仍在用。适合谁适合手上有标注数据、需要快速搭一套可解释、可微调的中文实体抽取流水线的工程师也适合想从「调包」进阶到「自己训模型」的 Python 开发者。下面我按「数据怎么准备 → 模型怎么搭 → 怎么训 → 怎么避坑 → 怎么调优」的顺序把这条链路完整走一遍。2. 数据准备与标签体系从原始文本到 BERT 能吃的输入2.1 中文 NER 的标签体系怎么定中文 NER 最通用的标注格式是 BIO 或 BMES。BIO 简单直接B-XXX 表示实体开头I-XXX 表示实体内部O 表示非实体。BMES 更细B 开头、M 中间、E 结尾、S 单字实体对边界敏感的任务比如地址、药品名更友好。我一般推荐新手先用 BIO标注成本低工具兼容性好。标签集合取决于你的业务。通用场景常见的有 PER人名、LOC地名、ORG机构名、TIME时间。垂直领域要自己扩展比如医疗加 DIS疾病、DRUG药品、SYM症状金融加 STOCK股票、AMT金额。标签数量直接决定 CRF 转移矩阵的大小标签越多训练越容易过拟合所以别一上来就搞三四十个标签。一个容易翻车的点是实体嵌套。比如「北京大学第三医院」既是 ORG 又包含 LOCBIO 体系处理不了嵌套只能选一个主标签。如果业务强依赖嵌套实体得换 Span-based 或者多层标注方案这套 BERTBILSTMCRF 就不太够用了。2.2 把标注数据转成模型输入假设你拿到的是 JSON 格式的标注数据每条包含text和labels与字符一一对应的标签列表。BERT 的 tokenizer 对中文是按字切分但会插入 [CLS]、[SEP]还可能把某些字符拆成 subword。所以对齐是第一个大坑。from transformers import BertTokenizer import torch tokenizer BertTokenizer.from_pretrained(bert-base-chinese) label2id {O: 0, B-PER: 1, I-PER: 2, B-LOC: 3, I-LOC: 4, B-ORG: 5, I-ORG: 6, [PAD]: 7, [CLS]: 8, [SEP]: 9} def convert_to_features(text, labels, max_len128): tokens [[CLS]] list(text) [[SEP]] label_ids [label2id[[CLS]]] [label2id[l] for l in labels] [label2id[[SEP]]] # 截断 if len(tokens) max_len: tokens tokens[:max_len] label_ids label_ids[:max_len] # padding attention_mask [1] * len(tokens) while len(tokens) max_len: tokens.append([PAD]) label_ids.append(label2id[[PAD]]) attention_mask.append(0) input_ids tokenizer.convert_tokens_to_ids(tokens) return { input_ids: torch.tensor(input_ids), attention_mask: torch.tensor(attention_mask), labels: torch.tensor(label_ids) }这段代码的逻辑是先手动拼 [CLS] 和 [SEP]保证标签和 token 严格对齐然后截断到 max_len再做 padding。参数说明max_len128适合短文本长文本合同、病历建议 256 或 512但显存占用会平方级增长。label2id里给 [PAD]、[CLS]、[SEP] 单独分配 id训练时要把这些位置的 loss 忽略掉否则模型会去学预测特殊符号。提示如果你的标注数据里实体边界和 BERT tokenizer 的切分不一致比如英文混中文一定要写单元测试验证对齐我见过太多因为错位一个字符导致 F1 掉 10 个点的血泪案例。2.3 数据集划分与 DataLoader按 8:1:1 划分训练、验证、测试集注意同一篇文档的句子不要跨集合否则会有信息泄漏。用 PyTorch 的 Dataset 封装from torch.utils.data import Dataset, DataLoader class NERDataset(Dataset): def __init__(self, samples, tokenizer, max_len128): self.samples samples self.tokenizer tokenizer self.max_len max_len def __len__(self): return len(self.samples) def __getitem__(self, idx): s self.samples[idx] return convert_to_features(s[text], s[labels], self.max_len) train_loader DataLoader(NERDataset(train_samples, tokenizer), batch_size32, shuffleTrue)batch_size32在 8G 显存的卡上跑 bert-base-chinese BILSTM 基本够用显存不够就降到 16 或 8同时把 max_len 调小。shuffle 只对训练集开验证和测试集保持顺序方便排查。3. 模型搭建BERT 输出怎么接 BILSTM 再接 CRF3.1 三个模块各自干什么BERT 的输出是每个 token 的 768 维向量base 版它已经包含了丰富的上下文语义但它是双向 Transformer对序列的「顺序约束」建模不如 RNN 显式。BILSTM 接在 BERT 后面进一步捕捉相邻标签之间的依赖同时把 768 维降到隐藏层维度常用 128 或 256减少 CRF 的计算量。CRF 层则学习标签之间的转移概率保证输出序列合法。为什么不能只用 BERT 线性分类因为线性分类是每个位置独立预测会出现「B-PER 后面跟 I-LOC」这种非法序列。CRF 通过维特比解码全局最优路径能显著提升边界准确率。实测在 MSRA 上加 CRF 比不加 F1 高 1.5 到 2 个点。3.2 完整模型代码import torch.nn as nn from transformers import BertModel from torchcrf import CRF 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) def forward(self, input_ids, attention_mask, labelsNone): outputs self.bert(input_idsinput_ids, attention_maskattention_mask) sequence_output self.dropout(outputs.last_hidden_state) lstm_out, _ self.bilstm(sequence_output) emissions self.classifier(lstm_out) if labels is not None: # 把 padding 位置的标签设为 -100CRF 内部会 mask 掉 mask attention_mask.bool() loss -self.crf(emissions, labels, maskmask, reductionmean) return loss else: return self.crf.decode(emissions, maskattention_mask.bool())逻辑说明BERT 输出last_hidden_state形状是(batch, seq_len, 768)BILSTM 输出(batch, seq_len, 512)双向 256 拼接classifier 映射到标签数CRF 接收 emissions 和真实标签算负对数似然损失。参数说明lstm_hidden256是经验值太小欠拟合太大过拟合且慢dropout0.3在 BERT 微调时常用数据量小可以加到 0.5num_layers1够用两层 BILSTM 收益递减还容易梯度问题。注意torchcrf这个库需要单独pip install pytorch-crf它内部已经处理了转移矩阵和维特比解码不用自己手写。如果你用的是 HuggingFace 的 Trainer需要自定义 compute_metrics 和模型 forward 返回格式。3.3 标签对齐与 loss 屏蔽训练时最关键的一步是把 [PAD]、[CLS]、[SEP] 位置的标签设成 -100 或者用 mask 屏蔽。上面代码里用attention_mask.bool()作为 CRF 的 maskCRF 内部会把 mask 为 0 的位置排除在转移计算之外。但 classifier 输出的 emissions 仍然包含这些位置所以更稳妥的做法是在算 loss 前把 labels 里对应位置改成 -100并在 CRF 里用mask参数。两种方式选一种别混用。如果发现 loss 一直不降先检查标签 id 有没有越界、mask 有没有传对、BERT 的attention_mask是不是全 1。这三个地方是新手最常见的翻车点。4. 训练、评估与推理把模型跑起来并验证效果4.1 训练循环与关键超参from transformers import AdamW from tqdm import tqdm device torch.device(cuda if torch.cuda.is_available() else cpu) model BertBiLstmCrf(bert-base-chinese, num_labelslen(label2id)).to(device) optimizer AdamW(model.parameters(), lr2e-5, weight_decay0.01) for epoch in range(10): model.train() total_loss 0 for batch in tqdm(train_loader): input_ids batch[input_ids].to(device) attention_mask batch[attention_mask].to(device) labels batch[labels].to(device) loss model(input_ids, attention_mask, labels) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) optimizer.step() optimizer.zero_grad() total_loss loss.item() print(fepoch {epoch}, loss {total_loss / len(train_loader):.4f})超参说明lr2e-5是 BERT 微调的标准学习率太大容易灾难性遗忘太小收敛慢weight_decay0.01防过拟合clip_grad_norm_设 1.0 防止梯度爆炸BILSTM 接 BERT 时梯度偶尔会飙。epoch 一般 5 到 10看验证集 F1 早停。4.2 用 seqeval 算实体级 F1token 级准确率会骗人因为 O 标签占大多数。必须用实体级评估from seqeval.metrics import classification_report, f1_score def evaluate(model, loader, id2label): model.eval() preds, trues [], [] with torch.no_grad(): for batch in loader: input_ids batch[input_ids].to(device) attention_mask batch[attention_mask].to(device) labels batch[labels].numpy() pred_ids model(input_ids, attention_mask) for p, l, m in zip(pred_ids, labels, attention_mask.numpy()): pred_seq [id2label[i] for i, mm in zip(p, m) if mm 1] true_seq [id2label[i] for i, mm in zip(l, m) if mm 1] preds.append(pred_seq) trues.append(true_seq) print(classification_report(trues, preds)) return f1_score(trues, preds)seqeval会把 B/I 标签合并成实体再比对输出每个实体类型的 precision、recall、F1。参数说明id2label是 label2id 的反转注意把 [PAD]、[CLS]、[SEP] 排除掉。如果某个实体类型 F1 特别低先看训练集里这类实体有多少条少于 200 条基本学不好。4.3 推理与后处理def predict(text, model, tokenizer, id2label, max_len128): model.eval() tokens [[CLS]] list(text) [[SEP]] input_ids tokenizer.convert_tokens_to_ids(tokens)[:max_len] attention_mask [1] * len(input_ids) input_ids torch.tensor([input_ids]).to(device) attention_mask torch.tensor([attention_mask]).to(device) with torch.no_grad(): pred_ids model(input_ids, attention_mask)[0] entities [] current None for char, pid in zip(list(text), pred_ids[1:len(text)1]): label id2label[pid] if label.startswith(B-): if current: entities.append(current) current {type: label[2:], text: char} elif label.startswith(I-) and current: current[text] char else: if current: entities.append(current) current None if current: entities.append(current) return entities后处理逻辑遇到 B- 开新实体遇到 I- 追加遇到 O 或新 B- 就收尾。注意 pred_ids 要去掉 [CLS] 和 [SEP] 的位置。实际业务里还要加规则兜底比如时间实体用正则再捞一遍模型和规则互补。提示推理时 batch 化能显著提速但要注意 padding 后的 mask 对齐。单条推理在 CPU 上大概 50msGPU 上 5ms 以内生产环境建议用 ONNX 或 TensorRT 导出。5. 避坑与排查中文 NER 训练中最容易翻车的 5 个点5.1 现象loss 降到 0.1 以下但 F1 只有 60%原因标签泄漏或者数据泄漏。常见情况是验证集和训练集有重复句子或者 [CLS]、[SEP] 位置的标签没屏蔽模型学会了直接复制输入。解决检查数据集划分有没有重叠确认 CRF 的 mask 传的是 attention_mask 而不是全 1把特殊位置的 label 设成 -100。5.2 现象某些实体类型 F1 为 0原因训练集里该类型样本太少或者标签定义有歧义。比如「苹果」有时是 ORG 有时是 FOOD标注不一致。解决统计每类实体数量少于 200 条的先合并或补充标注写标注规范文档让标注员对齐边界规则。5.3 现象训练到第 3 个 epoch 后验证集 F1 开始下降原因过拟合。BERT 参数量大小数据集上很容易记住训练样本。解决加大 dropout 到 0.5加 weight_decay早停patience2或者冻结 BERT 前 6 层只微调后 6 层。数据量少于 5000 条时冻结更多层往往更稳。5.4 现象推理时实体边界多一个字或少一个字原因BIO 标注边界和 tokenizer 切分不一致或者后处理逻辑有 bug。解决写单元测试用「北京大学」这种已知实体验证输出检查 tokenizer 是否把某些字拆成了 subword如果是需要在对齐时把 subword 的标签设成 -100 或复制首字标签。5.5 现象GPU 显存溢出OOM原因max_len 太大、batch_size 太大、或者 BILSTM 隐藏层太宽。解决先把 batch_size 降到 8max_len 降到 128lstm_hidden 降到 128。还可以用梯度累积模拟大 batchloss loss / accum_steps; loss.backward()每 accum_steps 步再 optimizer.step()。6. 进阶调优让 BERTBILSTMCRF 在垂直领域多拿 3 个点6.1 用领域预训练模型替换 bert-base-chinese通用 BERT 在医疗、金融、法律领域表现会打折。如果手上有领域语料继续做 MLM 预训练或者直接用现成的领域模型比如医疗领域的 MC-BERT、金融领域的 FinBERT。替换方式很简单把BertModel.from_pretrained(bert-base-chinese)里的路径换成领域模型路径即可tokenizer 也要同步换。实测在病历数据上领域预训练能带来 2 到 4 个点的 F1 提升。6.2 对抗训练与 FGM在 embedding 层加扰动做对抗训练能提升泛化。FGMFast Gradient Method实现只要十几行class FGM: def __init__(self, model, epsilon1.0): self.model model self.epsilon epsilon self.backup {} def attack(self): 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 self.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太大反而掉点。这个技巧在数据量少的时候特别管用我一般会在验证集 F1 卡住时加上试试。6.3 学习率预热与分层衰减BERT 微调建议用 warmup前 10% 步数线性升温之后余弦衰减。HuggingFace 的get_linear_schedule_with_warmup直接可用。另外可以对 BERT 底层用更小的学习率顶层和 BILSTM、CRF 用正常学习率分层设置optimizer_grouped_parameters [ {params: model.bert.parameters(), lr: 1e-5}, {params: model.bilstm.parameters(), lr: 1e-3}, {params: model.classifier.parameters(), lr: 1e-3}, {params: model.crf.parameters(), lr: 1e-3}, ] optimizer AdamW(optimizer_grouped_parameters, weight_decay0.01)这样底层不轻易被破坏顶层快速适配任务。实测比统一学习率稳尤其在标注数据只有几千条的时候。6.4 模型集成与投票单模型 F1 到 92 之后再想涨点就得靠集成。训 3 到 5 个不同随机种子的模型推理时对每个 token 的标签做投票或者对 emissions 取平均再走 CRF 解码。投票实现简单但要注意标签 id 对齐。集成一般能再涨 0.5 到 1 个点代价是推理成本翻倍看业务能不能接受。最后说个我自己的习惯每次训完模型我都会拿 20 条典型 badcase 逐条看是边界问题、标签歧义还是数据缺失比盯着 F1 数字有用得多。这套 BERTBILSTMCRF 方案不新但胜在稳定、可解释、好调垂直领域把数据和领域预训练做扎实90% 以上的场景都能覆盖。希望帮到你。本文还有配套的精品资源点击获取