
简介这份资源面向计算机、人工智能、数据科学等专业的学生与开发者提供一套完整的中文命名实体识别实战方案采用BERTBILSTMCRF经典组合可用于课程设计、毕业设计或初期项目立项演示。压缩包共58个文件约13.75MB包含16个Python源码文件、19个编译缓存文件、9个文本说明、4份Markdown文档及若干图片与配置XML源码覆盖BERT_BILSTM_CRF、BILSTM_CRF、IDCNN_CRF、BILSTM_Attention_CRF等多种模型实现并附带人民日报与MSRA数据预处理脚本、词表构建、训练入口及已训练模型便于直接复现与对比实验。项目说明文档与README对目录结构和运行方式有清晰交代适合小白练手也适合进阶者研究不同网络结构的差异。目前已有1210人学习下载具备较高的参考与借鉴价值。1. 从一份 BERTBILSTMCRF 源码包说起中文命名实体识别到底难在哪中文命名实体识别NER是很多 NLP 项目绕不开的第一道坎。你手上有一批中文文本想从里面自动抽出人名、地名、机构名、时间、产品型号靠正则写规则写到崩溃靠词典又覆盖不全最后大概率会落到「BERT BiLSTM CRF」这套组合上。这份源码包标题里同时出现了 BERT、BiLSTM、CRF、Python 源码、模型和数据说明它想给的是一条完整链路预训练语义表示、上下文序列建模、标签转移约束再加上能直接跑起来的数据和权重。它适合两类人一类是刚学完 Python 和 PyTorch想找一个结构清晰的中文 NER 项目练手另一类是已经在做信息抽取、知识图谱、舆情分析需要一个能改标签体系、能换数据、能复现指标的基线。真正难的地方不在「三个模块拼起来」而在中文没有天然空格分词、实体边界模糊、标签体系一改全链路都要动、训练时 loss 不降或验证集 F1 虚高。下面按「先立住原理再动手复现最后讲坑」的顺序拆开讲。2. BERTBILSTMCRF 三层结构每一层到底在补什么2.1 为什么不是 BERT 直接接 softmax很多人第一次做 NER 会想BERT 已经这么强后面接一个全连接分类不就行了在简单场景下确实能跑但序列标注和普通文本分类有本质区别。普通分类每个样本一个标签序列标注每个 token 一个标签而且标签之间有强约束。比如用 BIO 标注时I-PER 前面必须是 B-PER 或 I-PER不可能凭空出现一个 I-PER。softmax 对每个位置独立打分学不到这种转移关系输出里就会出现「B-PER 后面跟 I-LOC」这种非法序列。BiLSTM 的作用是再叠一层上下文建模。BERT 本身已经带双向注意力但它的输出偏向通用语义表示BiLSTM 可以在任务数据上进一步捕捉相邻标签的局部依赖尤其是实体跨多个 token 时前向和后向隐藏状态拼接能让每个位置的表示同时看到左右文。CRF 则放在最后把「每个位置选哪个标签」变成「整条标签序列哪条最优」用转移矩阵约束非法跳转。三层各司其职BERT 管「这个词在句子里是什么意思」BiLSTM 管「这个位置前后标签怎么过渡」CRF 管「整条路径合不合法」。2.2 标签体系与数据格式先定死动手前必须先确定标签体系因为后面数据处理、模型输出维度、评估脚本全依赖它。中文 NER 最常见的是 BIO 和 BIOES 两套。BIO 简单B-XXX 表示实体开始I-XXX 表示实体内部BIOES 多了 E-XXX结束和 S-XXX单字实体边界更清晰但标注成本高。源码包里如果已经给了数据先看它的标签文件不要自己另起一套。标签体系标签示例优点缺点BIOB-PER, I-PER, O标注简单兼容性好单字实体和边界易混BIOESB-PER, I-PER, E-PER, S-PER, O边界明确指标通常更高标注和转换成本高BMESB-PER, M-PER, E-PER, S-PER中文分词常用与 NER 工具链对接需转换数据一般是一行一个字符加标签句子之间空行。常见做法是把原始数据整理成如下格式再写脚本转成 id 序列北 B-LOC 京 I-LOC 大 I-ORG 学 I-ORG 李 B-PER 明 I-PER 在 O 清 O 华 O 大 O 学 O 读 O 书 O提示标签文件里 O 的数量通常远多于实体标签直接训练会让模型倾向全预测 O后面讲损失函数时要处理。2.3 模型前向过程与维度变化把三层串起来看输入是一句中文输出是每个字的标签。假设 batch size 为 B序列长度为 LBERT 隐藏维度为 H标签数为 C。BERT 输出形状是 (B, L, H)BiLSTM 输出形状仍是 (B, L, 2*hidden_size)再经过一个线性层映射到 (B, L, C)最后送入 CRF 计算损失或解码。关键参数是 BiLSTM 的 hidden_size一般取 BERT 隐藏维度的一半或 256太大容易过拟合太小欠拟合。import torch import torch.nn as nn from transformers import BertModel class BertBiLstmCrf(nn.Module): def __init__(self, bert_path, num_tags, lstm_hidden256): super().__init__() # BERT 负责通用语义表示输出每个 token 的上下文向量 self.bert BertModel.from_pretrained(bert_path) bert_hidden self.bert.config.hidden_size # BiLSTM 进一步建模标签附近的局部依赖 self.bilstm nn.LSTM( input_sizebert_hidden, hidden_sizelstm_hidden, num_layers1, batch_firstTrue, bidirectionalTrue, ) # 线性层把 BiLSTM 输出映射到标签空间 self.classifier nn.Linear(lstm_hidden * 2, num_tags) # CRF 层负责整条标签序列的转移约束 self.crf CRF(num_tags, batch_firstTrue) def forward(self, input_ids, attention_mask, tagsNone): outputs self.bert(input_idsinput_ids, attention_maskattention_mask) sequence_output outputs.last_hidden_state lstm_output, _ self.bilstm(sequence_output) emissions self.classifier(lstm_output) if tags is not None: # 训练时用 CRF 负对数似然作为损失 loss -self.crf(emissions, tags, maskattention_mask.bool(), reductionmean) return loss # 推理时用 Viterbi 解码出最优标签路径 return self.crf.decode(emissions, maskattention_mask.bool())上面代码里lstm_hidden控制 BiLSTM 容量num_tags必须和标签文件严格一致attention_mask用来屏蔽 padding 位置否则 CRF 会把补齐的 0 也当成有效标签参与转移。reductionmean表示对 batch 内有效 token 求平均损失如果数据里长句多可以改成sum再手动归一化。3. 把源码包跑起来环境、数据、训练、推理四步3.1 环境安装与依赖版本对齐这类项目最常见的翻车点不是模型写错而是环境版本对不上。BERT 相关代码依赖 transformersCRF 层常用 pytorch-crf训练脚本还依赖 numpy、tqdm、scikit-learn。建议单独建虚拟环境Python 用 3.8 或 3.9PyTorch 和 CUDA 版本按自己显卡选。不要直接pip install一堆最新版transformers 新版本经常改 API老项目里的BertModel.from_pretrained参数可能已经废弃。# 创建并激活虚拟环境避免污染系统 Python python -m venv ner_env source ner_env/bin/activate # Windows 用 ner_env\Scripts\activate # 安装核心依赖版本按项目 requirements.txt 为准 pip install torch1.13.1cu117 -f https://download.pytorch.org/whl/torch_stable.html pip install transformers4.28.1 pip install pytorch-crf0.7.2 pip install numpy scikit-learn tqdm如果项目里带了requirements.txt优先用它。安装完先跑一句python -c import torch; print(torch.cuda.is_available())确认 GPU 可用。如果返回 False后面训练会慢到怀疑人生先解决驱动和 CUDA 匹配问题再谈调参。3.2 数据读取与标签对齐数据脚本一般要做四件事读原始文件、建立字表和标签表、把句子转成 id、做 padding 和 mask。中文 NER 通常按字切分不额外分词因为 BERT 自带中文词表按字输入能避免分词错误传播。标签表要把 O 放在索引 0方便后面计算 mask。padding 时 input_ids 补 0attention_mask 补 0标签补 -100 或忽略索引确保 CRF 不计算这些位置。from torch.utils.data import Dataset import torch class NerDataset(Dataset): def __init__(self, samples, tokenizer, label2id, max_len128): self.samples samples self.tokenizer tokenizer self.label2id label2id self.max_len max_len def __len__(self): return len(self.samples) def __getitem__(self, idx): chars, labels self.samples[idx] # 按字转 id不加特殊符号时需与训练时保持一致 input_ids self.tokenizer.convert_tokens_to_ids(chars) label_ids [self.label2id[l] for l in labels] # 截断和补齐保证同一 batch 内长度一致 input_ids input_ids[:self.max_len] label_ids label_ids[:self.max_len] attention_mask [1] * len(input_ids) pad_len self.max_len - len(input_ids) input_ids [0] * pad_len label_ids [-100] * pad_len attention_mask [0] * pad_len return { input_ids: torch.tensor(input_ids, dtypetorch.long), attention_mask: torch.tensor(attention_mask, dtypetorch.long), labels: torch.tensor(label_ids, dtypetorch.long), }这里max_len128是常见起点中文 NER 句子一般不会太长设太大浪费显存。label_ids补齐用 -100 是为了在计算 loss 时忽略但 pytorch-crf 的接口不一定认 -100需要在模型里把 mask 传进去或者把补齐标签换成 O 的 id 再用 mask 屏蔽。两种做法都行关键是训练和推理保持一致。3.3 训练循环与关键超参训练脚本的核心是前向、算 loss、反向、更新。BERT 部分学习率要小通常 2e-5 到 5e-5BiLSTM 和分类层可以用大一点1e-3 左右。如果统一用一个优化器建议分组设置参数否则 BERT 容易被大学习率带崩。batch size 在 16 到 32 之间显存不够就梯度累积。训练轮数一般 3 到 10 轮看验证集 F1 早停。from torch.optim import AdamW from transformers import get_linear_schedule_with_warmup # 分组设置学习率BERT 用小学习率其余层用大学习率 bert_params list(model.bert.named_parameters()) other_params [(n, p) for n, p in model.named_parameters() if not n.startswith(bert.)] optimizer AdamW([ {params: [p for _, p in bert_params], lr: 2e-5}, {params: [p for _, p in other_params], lr: 1e-3}, ]) total_steps len(train_loader) * epochs scheduler get_linear_schedule_with_warmup( optimizer, num_warmup_stepsint(0.1 * total_steps), num_training_stepstotal_steps ) for epoch in range(epochs): model.train() for batch in train_loader: optimizer.zero_grad() loss model( input_idsbatch[input_ids].to(device), attention_maskbatch[attention_mask].to(device), tagsbatch[labels].to(device), ) loss.backward() # 梯度裁剪防止 BERT 微调时梯度爆炸 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) optimizer.step() scheduler.step()max_norm1.0是梯度裁剪阈值中文 NER 数据量小的时候尤其要加否则 loss 可能突然变 NaN。num_warmup_steps取总步数 10% 是常见做法让学习率从 0 慢慢升上去避免一开始就把预训练权重带偏。3.4 推理与实体还原推理阶段用crf.decode拿到每个位置的标签 id再映射回标签名最后把连续的 B/I 合并成完整实体。注意 BERT 如果加了 [CLS] 和 [SEP]要跳过这两个位置否则会把特殊符号也当成字。合并时遇到 B-XXX 开新实体遇到 I-XXX 追加到当前实体遇到 O 或不同类型标签就收尾。def decode_entities(chars, tag_ids, id2label): entities [] current None for ch, tid in zip(chars, tag_ids): tag id2label[tid] if tag.startswith(B-): if current: entities.append(current) current {type: tag[2:], text: ch} elif tag.startswith(I-) and current and current[type] tag[2:]: current[text] ch else: if current: entities.append(current) current None if current: entities.append(current) return entities这段逻辑看着简单但实际跑起来最容易在「B-XXX 后面跟了 I-YYY」时出错所以前面 CRF 的转移约束很重要。如果发现输出实体边界总差一个字先检查标签对齐有没有错位再检查 tokenizer 有没有把某些字拆成多 token。4. 避坑与排查中文 NER 训练里最常见的 5 个翻车现场4.1 loss 不降或直接变 NaN现象是训练几个 step 后 loss 变成 nan或者一直卡在 8 点几不动。原因通常是学习率太大、梯度爆炸、标签 id 越界、或者 CRF 的 mask 没传对。解决顺序先把学习率降到 1e-5 试一轮加上梯度裁剪检查标签文件里最大 id 是否小于 num_tags确认 attention_mask 在 CRF 里正确屏蔽了 padding。如果用了混合精度先关掉 amp 再跑。4.2 验证集 F1 很高但实际抽取一塌糊涂现象是脚本打印的 F1 到 0.95但拿真实句子去预测实体缺胳膊少腿。原因多半是数据泄漏或评估脚本有 bug比如验证集和训练集有重复句子或者评估时把 O 也算进正确率导致虚高。解决方法是手动切分数据确保同一实体不出现在两边评估时用 seqeval 或自己写严格匹配只统计实体级别的 precision、recall、F1不看 token 准确率。4.3 中文标点和英文标点导致标签错位现象是训练时正常推理时遇到全角逗号、引号就多出或漏掉实体。原因是数据预处理时把标点替换或删除了但标签没同步调整。解决方法是预处理阶段保持字符和标签一一对应不要做任何会改变长度的清洗。如果必须归一化标点就在标注前做而不是训练时做。4.4 显存不够导致 batch size 只能设 1现象是 8G 显存跑 BERTBiLSTMCRF 直接 OOM。原因是 BERT 本身占显存BiLSTM 又加了一层max_len 设太大更雪上加霜。解决办法把 max_len 从 128 降到 64batch size 设 8 并用梯度累积模拟大 batch或者冻结 BERT 前几层。如果还不行换更小的预训练模型比如 bert-base-chinese 换成四层的小模型。4.5 自定义标签体系后模型输出全乱现象是加了新实体类型重新训练后旧实体也识别不准。原因是标签表变了但预训练模型和 CRF 转移矩阵要从头学数据量不够时新标签会挤占旧标签的表达空间。解决办法是先用旧数据预训练一版再在新数据上微调或者把新标签单独做一个二分类模型不要硬塞进同一个 CRF。5. 进阶技巧用对抗训练和标签平滑把 F1 再抬两个点如果基线已经跑通指标卡在 0.85 左右上不去可以试两个成本低、见效稳的技巧。第一个是 FGM 对抗训练在 embedding 层加扰动让模型对输入小变化更鲁棒。第二个是标签平滑把硬标签 0/1 换成 0.1/0.9缓解 O 标签过多带来的过拟合。这两个方法在中文 NER 上通常能带来 1 到 2 个点的 F1 提升而且代码改动很小。# FGM 对抗训练核心逻辑在 embedding 上加扰动再算一次 loss 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 word_embeddings 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 {}用法是在正常反向传播后调用fgm.attack()再前向算一次 loss 并反向最后fgm.restore()恢复参数。epsilon一般取 0.5 到 1.0太大反而掉点。标签平滑可以直接在 CrossEntropyLoss 里设label_smoothing0.1但 CRF 的负对数似然不直接支持需要自己改损失函数或者在 emission 层面做平滑。验证方法很简单固定随机种子跑三次取平均对比加与不加的验证集 F1。如果提升不到 0.5 个点说明数据量太小或标签噪声太大先回去洗数据比调模型更划算。我自己做中文 NER 的习惯是任何技巧上线前必须用同一份测试集跑三遍只看实体级 F1不看 loss 曲线好不好看。模型这东西指标稳比曲线漂亮重要得多。希望帮到你。本文还有配套的精品资源点击获取