简介这是一份中文命名实体识别完整Python源码包覆盖BILSTMCRF、IDCNNCRF、BERTBILSTMCRF三种主流模型适合自然语言处理初学者、高校学生及需要完成课程设计或毕业设计的开发者。压缩包共58个文件、整体约13.75MB其中16个py脚本为核心实现19个pyc为依赖缓存9个txt和4个md提供说明文档另有5张png示意图、少量xml配置与csv数据文件目录包含数据预处理、模型定义、训练与工具模块层次清晰。代码按模型拆分为IDCNN_CRF、BILSTM_CRF、BERT_BILSTM_CRF等独立入口并附带人民日报、MSRA、data2等数据集的预处理脚本及train.py训练流程便于开展模型效果对照实验可对照模型差异、直接复现或二次开发。资源内项目均调试通过目前已有525人学习下载无论想快速跑通基线、对比BERT引入后的效果还是将NER模块接入自己的毕设或课设都能基于这套源码较顺畅地完成。1. 中文命名实体识别为什么绕不开这三种模型组合做合同要素抽取、病历结构化、工单自动分类时第一个卡住的往往不是分类模型而是把“中国石油”“张三丰”这类人名、地名、机构名从一长段中文里捞出来。这就是中文命名实体识别中文NER要解决的问题。市面上能直接跑的 Python 源码绝大多数绕不开 BILSTMCRF、IDCNNCRF、BERTBILSTMCRF 这三条路线一条是序列标注的经典基线一条是不吃预训练模型也能有一定感受野的卷积方案一条是刷榜和上生产的首选。它们的共同点是都依赖 CRF 做标签约束区别只在特征抽取器怎么把上下文信息编码出来。这篇我会把每条路线的数据准备、模型结构、核心参数和常见翻车点写透源码以 PyTorch 为主适合想快速落地、不想被论文公式劝退的读者。2. 先把数据喂对中文NER的字符切分、BIOES标注与字典构建2.1 中文NER为什么按单字切而不是按词切中文NER和英文NER最大的区别在于基本单元。英文按空格分词词边界天然存在中文如果按词切就需要先跑一个分词器一旦分词出错实体边界就跟着错。更麻烦的是中文里“中华/人民/共和国”切错了整个标签序列就废了。所以业界做中文NER的默认做法是按单字character-level切分一个汉字一个 token标点符号也单独占一个 token。这样做还有一个好处OOV未登录词问题被压到最小。按词切的话专业术语、人名生僻字很容易掉出词表按字切只要字典里覆盖了常用汉字实体里几乎不会出现未登录 token。代价是序列变长一句 50 个字的电话号加地址切成 token 后有 60 多个对后续模型的计算量和 CRF 的路径搜索都会产生影响。这个代价在实际工程里是值得的因为标签错位的概率低了一大截。2.2 构造字典与标签序列一个 build_dataset 的完整实现数据预处理的产物是三样东西字表word2idx、标签表label2idx、以及把它们编码成 id 的训练样本。中文NER的标签体系常用 BIOBegin/Inside/Outside或 BIOES多一个 End/Single。BIOES 对实体边界的刻画更细CRF 学起来更容易收束我一般默认用 BIOES。下面这段代码是把原始标注文本转成模型输入的常见做法def build_vocab(sentences, labels): word2idx {[PAD]: 0, [UNK]: 1, [CLS]: 2, [SEP]: 3} label2idx {O: 0} for chars, tag_seq in zip(sentences, labels): for c in chars: if c not in word2idx: word2idx[c] len(word2idx) for tag in tag_seq: if tag not in label2idx: label2idx[tag] len(label2idx) return word2idx, label2idx def encode_sample(chars, tags, word2idx, label2idx, max_len): input_ids [word2idx.get(c, word2idx[[UNK]]) for c in chars][:max_len] tag_ids [label2idx[t] for t in tags][:max_len] # 统一补到 max_len避免 DataLoader 拼 batch 时报错 input_ids [word2idx[[PAD]]] * (max_len - len(input_ids)) tag_ids [label2idx[O]] * (max_len - len(tag_ids)) mask [1] * min(len(chars), max_len) [0] * max(0, max_len - len(chars)) return input_ids, tag_ids, mask这段逻辑里三个关键点一是[PAD]的 label 补的是O而不是某个特殊 id因为 CRF 计算时会被 mask 掉补什么都行但补O最直观二是 mask 必须和标签一一对应否则 CRF 会把 padding 位置也当成真实标签去算转移概率三是[UNK]兜底防止验证集里出现训练集没见过的新字导致索引越界。如果你手头数据是词级别的标注建议写个脚本先按字展开成 character-level 标注再入模不要在模型侧做分词对齐否则标签很容易错位。2.3 实体类别不平衡O 类占比过高时要做的两件事中文文本里非实体字符通常占七成以上如果不做任何处理模型很容易学到“全部预测为 O”这种烂策略。常见做法是两件事一是在 loss 上给实体标签加权重二是用准确率之外的评价指标实体级 F1做早停和选模型。加权重可以直接改 CRF 的 loss 实现在 log 似然上对每个标签位置乘一个class_weight或者更省事一点只在训练时用torch.nn.CrossEntropyLoss预训练一个辅助分类头再切回 CRF 训练。我倾向于直接训练 CRF但把class_weight设成统计出来的标签频率倒数的平方根这样不用改模型结构。3. 从零搭 BILSTMCRF 与 IDCNNCRF两个非预训练模型的 PyTorch 实现3.1 网络结构图景BiLSTM 捕获上下文CRF 约束标签转移BILSTMCRF 的定位是序列标注的经典基线。BiLSTM 负责把每个字的上下文信息编码成特征向量CRF 负责在标签序列层面做全局约束比如 B-person 后面不能直接接 I-orgO 后面不能紧跟 I-org这类规则如果只靠 LSTM 输出每个位置独立 softmax是学不干净的。BiLSTM 输出的每个位置分布CRF 会结合转移矩阵重新打分选出全局最优路径。具体实现上BiLSTM 的输入是字向量序列可以是随机初始化的 embedding也可以是预训练词向量输出是每个位置的前向隐状态和后向隐状态拼接。拼接后的向量过一个线性层压缩到标签类别数得到 emission score。这个 score 不直接做 softmax而是喂给 CRF 做维特比解码。3.2 CRF 层自己写转移矩阵、前向评分与维特比解码很多读者会直接用torchcrf或fastNLP里现成的 CRF 层但理解 CRF 内部逻辑对调参和排错非常重要。下面这个 CRF 类覆盖了核心逻辑import torch import torch.nn as nn class CRF(nn.Module): def __init__(self, num_tags): super().__init__() self.num_tags num_tags # 转移矩阵transitions[i][j] 表示从标签 i 转移到标签 j 的得分 self.transitions nn.Parameter(torch.randn(num_tags, num_tags) * 0.01) self.start_transitions nn.Parameter(torch.randn(num_tags) * 0.01) self.end_transitions nn.Parameter(torch.randn(num_tags) * 0.01) def forward_alg(self, emissions, mask): # emissions: (batch, seq_len, num_tags) batch, seq_len, num_tags emissions.size() score self.start_transitions.expand(batch, -1) emissions[:, 0] for t in range(1, seq_len): current_emission emissions[:, t] # (batch, num_tags, 1) (num_tags, num_tags) (batch, 1, num_tags) next_score score.unsqueeze(-1) self.transitions.unsqueeze(0) current_emission.unsqueeze(1) next_score torch.logsumexp(next_score, dim1) # 只有 mask 为 1 的位置才更新累计得分 score torch.where(mask[:, t].unsqueeze(-1), next_score, score) final_score score self.end_transitions.unsqueeze(0) return torch.logsumexp(final_score, dim1) def viterbi_decode(self, emissions, mask): batch, seq_len, num_tags emissions.size() score self.start_transitions.expand(batch, -1) emissions[:, 0] backpointers [] for t in range(1, seq_len): next_score score.unsqueeze(-1) self.transitions.unsqueeze(0) emissions[:, t].unsqueeze(1) best_score, best_tag torch.max(next_score, dim1) backpointers.append(best_tag) score torch.where(mask[:, t].unsqueeze(-1), best_score, score) final_score score self.end_transitions.unsqueeze(0) _, best_last_tag torch.max(final_score, dim1) paths [best_last_tag] for bptrs in reversed(backpointers): paths.append(bptrs.gather(1, paths[-1].unsqueeze(1)).squeeze(1)) return torch.stack(paths, dim1)[:, ::-1]参数说明里最重要的一条torch.logsumexp替代手工torch.exp再torch.log避免指数上溢。转移矩阵初始化乘以 0.01 是为了让初始转移得分接近零如果初始化得太大CRF 前期会偏向于走某条固定标签路径实体类别多的时候收敛很慢。mask 的逐时间步传递容易漏很多实现只在 loss 里 mask但forward_alg和viterbi_decode里也要 mask不然 padding 位置的假标签会污染转移矩阵的学习。3.3 IDCNN 实现膨胀卷积块与残差连接IDCNNIterated Dilated CNN的思路是用膨胀卷积扩大感受野替代 BiLSTM 的序列依赖。相比 LSTMCNN 的好处是训练速度快、显存占用低而且在 GPU 上并行度更高缺点是感受野有限。IDCNN 的做法是把多个膨胀率不同的卷积层串起来让高层能看到更长的上下文。import torch import torch.nn as nn class DilatedConvBlock(nn.Module): def __init__(self, hidden_size, dilation): super().__init__() self.conv1 nn.Conv1d(hidden_size, hidden_size, kernel_size3, paddingdilation, dilationdilation) self.conv2 nn.Conv1d(hidden_size, hidden_size, kernel_size3, paddingdilation, dilationdilation) self.relu nn.ReLU() self.dropout nn.Dropout(0.2) def forward(self, x): # x: (batch, hidden, seq_len) out self.relu(self.conv1(x)) out self.dropout(out) out self.conv2(out) return self.relu(out x) # 残差连接缓解深层网络梯度消失这里paddingdilation的目的是让卷积输出长度和输入保持一致不需要额外做长度对齐。整个 IDCNN 一般堆 3 到 4 个 block每层 block 的 dilation 依次设为 1、2、4、8最终每个位置的感受野能覆盖几十个字符。hidden_size 通常取 128 或 256太小学不到复杂实体模式太大显存翻倍但 F1 提升有限。如果实体普遍很短人名、地名dilation 到 4 就够如果要识别“中国石油天然气集团有限公司”这种超长机构名建议把 dilation 序列加长到 16。IDCNN 的输入和 BiLSTM 不同它需要的是(batch, hidden, seq_len)的三维张量所以 embedding 输出后要先做维度转置。如果你拿到的手写 embedding 层输出是(batch, seq_len, hidden)记得x.permute(0, 2, 1)一下。3.4 两种非预训练模型的选型与必备训练参数BILSTMCRF 适合数据集规模中等1 万到 10 万句、实体类型偏结构化、上下文语境相对固定的场景。IDCNNCRF 更适合对时延敏感、训练资源受限、数据量在几千句的小项目。两者的训练参数我一般这样设embedding 维度 100 或 200LSTM hidden_size 取 128双向拼接后是 256dropout 取 0.5batch_size 取 16 到 32初始学习率 1e-3每隔 2 个 epoch 在验证集上算实体级 F1连续 3 个 epoch 不涨就降一半学习率。有一个常见误区有人喜欢给 BiLSTM 预训练一个 word2vec 或 glove 向量。中文NER 里这个操作收益不稳定因为字级别的 embedding 需要的是“字在特定实体上下文里的语义”而 word2vec 学的是词级别共现对字符级别帮助有限。我现在一般直接随机初始化 embedding把训练预算留在 CRF 和 LSTM 上效果反而更稳定。4. BERTBILSTMCRF预训练特征如何与序列标注模块拼合4.1 用 transformers 接入 BERT 并处理 padding 与 maskBERTBILSTMCRF 和前面两种模型最大的差别在输入侧BERT 吃的是 token 序列输出是每个 token 的上下文语义向量768 维。这些向量替代了随机初始化的 embedding进入 BiLSTM 和 CRF。代码结构上其实是把nn.Embedding换成BertModel。from transformers import BertModel, BertConfig import torch.nn as nn class BertBilstmCrf(nn.Module): def __init__(self, bert_dir, num_tags, hidden_size256, dropout0.5): super().__init__() self.bert BertModel.from_pretrained(bert_dir) self.bilstm nn.LSTM( input_size768, hidden_sizehidden_size // 2, num_layers1, batch_firstTrue, bidirectionalTrue, dropoutdropout ) self.classifier nn.Linear(hidden_size, num_tags) self.crf CRF(num_tags) def forward(self, input_ids, attention_mask, labelsNone): # 传入 attention_mask让 BERT 自己屏蔽 padding 位置 bert_output self.bert(input_idsinput_ids, attention_maskattention_mask) sequence_output bert_output.last_hidden_state # (batch, seq_len, 768) lstm_output, _ self.bilstm(sequence_output) emissions self.classifier(lstm_output) if labels is not None: # 这里需要把 attention_mask 转成 bool 型1 表示有效0 表示 padding loss -self.crf.forward_alg(emissions, attention_mask.bool()) # 还要减去真实标签路径的得分构造负对数似然 loss loss - self.crf.score(emissions, labels, attention_mask.bool()) return loss.mean() pred self.crf.viterbi_decode(emissions, attention_mask.bool()) return pred这里最关键的坑是 attention_mask 要透传到 CRF。BERT 的输出层会自动处理 padding但 BiLSTM 不会CRF 更不会。如果你只把 attention_mask 传给 BERTCRF 在计算转移路径时会把 padding 位置也当成真实 token 去算轻则 F1 偏低重则训练不收敛。另外一个细节是 BERT 的 tokenizer 会把一些英文或数字拆成 subword中文基本一字一 token但如果你文本里混着英文和数字input_ids长度会不等于字符数。这时候标签序列要和 BERT tokenizer 的 token 序列对齐不能用原始字符标签硬怼。4.2 分段学习率BERT 与下游模块的四个必调参数BERT 预训练参数和随机初始化的下游模块LSTM、CRF、线性层对学习率的敏感度完全不同。BERT 本身已经收敛到很平滑的损失面学习率大了直接灾难性遗忘小了下游模块不收敛。常见做法是用 AdamW 分配两段学习率from torch.optim import AdamW optimizer AdamW([ {params: model.bert.parameters(), lr: 2e-5}, {params: model.bilstm.parameters(), lr: 1e-3}, {params: model.classifier.parameters(), lr: 1e-3}, {params: model.crf.parameters(), lr: 1e-3}, ], weight_decay0.01)参数设置思路BERT 部分用 2e-5 到 5e-5 之间超过 5e-5 很常见的情况是验证集 F1 先涨后崩下游模块用 1e-3因为它们是随机初始化需要更大的步长快速收敛。max_len 建议设 128中文一句话平均 30 到 50 个字128 基本覆盖绝大多数情况超过 128 的句子截断比硬塞进模型更划算。batch_size 在单卡 11G 显存上BERT-base 加 LSTM 加 CRF 设 16 比较稳超过 32 大概率 OOM。还有一个容易忽略的是 warmup。BERT 刚加载时对输入 distribution 有个适应过程前 10% 的 step 用很小的学习率热身我用的是get_linear_schedule_with_warmup步数设成总步数的 10%。4.3 何时可以去掉 BiLSTM只留 BERTCRFBERT 输出的句向量本身已经带了双向上下文信息BiLSTM 再加一层序列编码很多时候属于锦上添花而不是雪中送炭。在数据量少于 1 万句、实体类型固定比如只抽手机号和身份证号时BERTBILSTMCRF 反而容易过拟合因为 LSTM 层多出来的参数没有足够数据约束。实测中常见的情况是BERTCRF把 LSTM 换成线性层直接映射到标签数在 5000 句左右的小数据集上比 BERTBILSTMCRF 高出 1 到 2 个点的 F1。做大项目时我会把两者都跑一遍用 BERT线性层CRF 当快速基线如果验证集 F1 显示实体内部标签错乱比如“阿里巴巴”标成了 B-org、I-org、O再上 BiLSTM 增强上下文建模。BERTBILSTMCRF 真正发挥优势的场景是长文本里嵌套了多个实体、且实体上下文高度相似比如法律文书里反复出现“原告”“被告”这时候 BiLSTM 对局部上下文的重组能力能帮 CRF 更好地区分边界。5. 三个模型实战避坑标签错位、显存爆炸与CRF不收敛的排查记录5.1 标签错位导致 CRF 训练损失正常但 F1 为 0现象训练 loss 稳步下降验证集实体级 F1 却一直是 0模型预测出的标签全员 O。原因标签序列和输入序列错位。最常见的是用 BERT tokenizer 处理文本后忘了处理 subword 拆分或者把原始字符标签直接硬贴到 token 序列上。Character-level 的数据一个“ah”英文单词会被 tokenizer 拆成两个 token原始标签只有一位对不上。解决统一走“先过 tokenizer再按 token 对齐标签”的流程。中文部分一字一 token 很好办遇到英文和数字在预处理时把连续的英文/数字块先合并成一个 token 位人工标注时也按块标。如果已经错位了训练前打印几条 input_ids 和标签的对照一眼就能看出来。5.2 显存爆炸BERT 在 16G 卡上跑不起来现象batch_size 设 32BERTBILSTMCRF 一跑就 CUDA out of memory把 batch 调到 8 还是偶尔爆。原因BERT-base 每句话要算 12 层 transformer中间激活值非常占显存加上 BiLSTM 的反向传播激活值进一步累积。序列长度稍微一长显存暴涨。解决第一是把 max_len 从 256 砍到 128中文实体很少跨 128 字第二是开梯度累积batch_size 设 8累积 4 步再更新参数效果和 batch 32 近似第三是用混合精度训练torch.cuda.amp的 GradScaler 能省一半显存F1 几乎无损最后实在不行就用 IDCNNCRF 替代 BiLSTM这部分能省出 2 到 3G 显存。5.3 CRF 把实体边界学成“一个实体内标签全部相同”现象预测结果里“北京大学”四个字全部标成 B-org或者全部标成 I-org边界完全错。原因CRF 的转移矩阵学出了问题。BIOES 标签体系里 B 和 I 的转移约束很强但如果训练数据较少转移矩阵里“B 到 B”和“I 到 I”的概率被学成了高概率模型倾向于整个连续片段用同一个标签。解决检查训练集标注质量很多手工标注数据存在“B 后面接 B”的错误样本CRF 会把这种错误当成规律。先写脚本统计训练集里所有转移对的频次看 B→B、I→O 这种非法转移占比是否偏高再考虑把 BIO 换成 BIOES 体系E 标签能强制模型在实体结束时切换状态边界识别会明显变稳。还有一个玄学点CRF 的转移矩阵初始化改成均匀分布torch.rand(num_tags, num_tags) * 0.1某些随机种子下能避免收敛到局部最优。5.4 IDCNN 把长机构名识别成两段现象IDCNNCRF 在“中国石油天然气集团有限公司”上只识别出“中国石油”和“天然气集团”中间断开。原因IDCNN 的感受野不够。dilation 只堆到 4 时每个位置的上下文窗口约 13 个字符长实体中间的位置看不到实体开头和结尾CRF 拿到的 emission score 不够强模型选择在中间断开。解决把 dilation 序列改成 1、2、4、8、16或者重复两个同样的膨胀卷积块。显存允许的话把 kernel_size 从 3 改成 5。改完之后在长实体样本上单独统计 F1不要只看全局指标全局 F1 容易被短实体刷上去。IDCNN 模型我不建议在实体平均长度超过 20 字的场景里硬刚换 BiLSTM 或 BERT 更稳。5.5 BERTBILSTMCRF 比 BERTCRF 还差模型训练不正常现象同一个数据集上BERTBILSTMCRF 验证集 F1 反而比去掉 BiLSTM 的版本低 2 个点以上。原因小数据过拟合。BiLSTM 那层参数是随机初始化的在 5000 句级别的数据上它学到了训练集特有的局部模式泛化能力不如直接线性映射。解决先用 BERTCRF 跑出一个 F1 基线再去调 BiLSTM。如果 BiLSTM 版本确实差检查它的 dropout 是否足够0.5 起步再检查 BiLSTM 的 hidden_size128 已经偏大时可以降到 64如果还不够就把 BiLSTM 换成一层nn.Linear(768, num_tags)不要为了“标题里有这个结构”而死守一个效果更差的模型。模型选型是工程问题不是论文复现。6. 三套模型横向验证用同一套评估脚本做选型决策模型不能只看训练 loss中文NER 只看准确率也没有意义因为 O 类占了大部分。我一般用实体级 precision、recall、F1 来做横向对比seqeval这个库算的就是实体级别的指标比 sklearn 的 token 级准确率靠谱得多from seqeval.metrics import classification_report, f1_score def evaluate(model, dataloader, idx2label): model.eval() true_entities, pred_entities [], [] with torch.no_grad(): for batch in dataloader: input_ids, masks, tag_ids batch pred_ids model.predict(input_ids, masks) for t, p, m in zip(tag_ids, pred_ids, masks): true_seq [idx2label[i] for i in t[:m.sum().item()]] pred_seq [idx2label[i] for i in p[:m.sum().item()]] true_entities.append(true_seq) pred_entities.append(pred_seq) print(classification_report(true_entities, pred_entities, digits4)) return f1_score(true_entities, pred_entities)用这个脚本把三种模型在同一个验证集上的 F1 打出来再结合训练时间、显存占用和推理时延做选型决策就有依据了。表格是我常用的判断模板模型组合数据量要求训练显存base 级推理速度适用场景BILSTMCRF1 万句以上2G 以内快通用基线、无预训练资源IDCNNCRF几千句可用1G 左右最快低时延、短实体为主BERTBILSTMCRF3 万句以上8G 到 12G慢长文本、边界复杂、高精度最后分享一个我养成的坏习惯改出来的经验拿到 NER 任务先不要急着上 BERT 全家桶先用 BILSTMCRF 跑一夜当基线记录它在哪里错再根据错误类型决定要不要换 IDCNN 或上 BERT。很多时候基线模型的错误集中在某一种实体类别上加几条规则或补标注就解决了不需要动用预训练模型。如果你直接上 BERTBILSTMCRF调参和排错的成本会翻好几倍而且很难说清到底是模型结构的问题还是数据的问题。希望帮到你。本文还有配套的精品资源点击获取