
简介面向中文命名实体识别任务的完整Python工程融合BERT-BILSTM-CRF模型可满足毕业设计、课程设计或项目实践需求。压缩包内共二十个文件涵盖六份Python脚本、八份JSON配置、五份TXT数据/标签文件与一份Markdown说明整体仅1.03MB结构紧凑已有1250人学习浏览。项目以dgre、duie两个数据集为例完整覆盖了原始语料清洗、BIO标注数据生成、模型训练、结果预测等环节。其中main.py可灵活调整最大序列长度、训练轮次和批大小predict.py能加载已训练模型进行实体抽取同时提供了chinese-bert-wwm-ext预训练模型目录并明确列出transformers、pytorch-crf、seqeval等依赖版本减少环境配置障碍。资源按模型保存、预训练权重、数据集等目录划分便于快速定位与二次开发。所有代码均经过测试可直接运行通用性与扩展性较好非常适合作为课设、毕设及中文NER入门的实用参考。1. 中文命名实体识别为什么非要BERT-BiLSTM-CRF这一套当你想从一堆医疗病历里抽药品名和疾病名或者从裁判文书中提取当事人信息第一反应是写规则词典但新词和同义表达会让规则快速崩坏。换成普通CRF又发现它只学到局部上下文“苹果”到底是水果还是公司名模型根本没谱。这两个场景是中文命名实体识别最常见的翻车现场而BERT-BiLSTM-CRF这套组合恰好把“语义理解”“时序依赖”“标签约束”三件事分开做成为近两年中文NER的标配方案。这篇文章要拆解的就是这样一个Python项目——包含源码、使用说明、数据与模型文件目标读者是想快速跑通中文NER、同时又想把每一步原理吃透的开发者和学生。2. 先拆模型BERT、BiLSTM、CRF在NER管线里到底干了什么活2.1 BERT把每个字变成带语境的向量而不是查静态词表传统词向量如word2vec用一个固定向量代表词“苹果”在“苹果很好吃”和“苹果公司发布新机”里是同一个向量模型分不出差异。中文NER是字级别任务更依赖上下文。BERT用多层Transformer对整句话做双向建模每个字经过层层自注意力后输出的向量已经包含了全句其他字的信息。也就是说“苹”“果”两个字在“很好吃”和“公司发布”两种语境里得到的向量是完全不同的。在Python项目中BERT部分通常通过Hugging Face的transformers库加载常见做法是用bert-base-chinese预训练权重。加载后取最后一层last_hidden_state作为每个字的特征维度是[batch_size, seq_len, 768]。代码一般长这样from transformers import BertTokenizer, BertModel tokenizer BertTokenizer.from_pretrained(bert-base-chinese) model BertModel.from_pretrained(bert-base-chinese) text 张三在北京上班 inputs tokenizer(text, return_tensorspt, max_length128, truncationTrue) outputs model(**inputs) # outputs.last_hidden_state 的形状是 [batch, seq_len, 768] hidden outputs.last_hidden_state参数说明max_length128限制单句最大长度超过部分截断truncationTrue表示启用截断。注意tokenizer会在句首和句尾自动插入[CLS]和[SEP]所以seq_len比原始字数多2。后续做序列标注时要么对齐标签要么在解码时去掉这两个位置。BERT的768维特征对后续BiLSTM来说正好是输入维度。2.2 BiLSTM把BERT的“字向量”按时间顺序再读一遍捕捉前后依赖BERT已经建模了上下文为什么还要再叠一个BiLSTM原因在于BERT是通用预训练模型直接接CRF的话整个模型容量太大在小规模标注数据上容易过拟合。BiLSTM相当于加了一个轻量的任务专用编码器能进一步聚焦当前语料中的时序依赖。它把句子从左到右和从右到左两个方向读两遍每个时刻的隐状态是前向和后向的拼接所以能同时看到某个字左边和右边的信息。项目中的BiLSTM层通常用PyTorch的nn.LSTM实现并设置bidirectionalTrue。定义参考如下import torch.nn as nn class BiLSTM(nn.Module): def __init__(self, input_size768, hidden_size256, num_layers2, dropout0.5): super().__init__() self.lstm nn.LSTM(input_size, hidden_size, num_layers, batch_firstTrue, bidirectionalTrue, dropoutdropout) self.fc nn.Linear(hidden_size * 2, num_tags) # 每个字输出所有标签的得分 def forward(self, x, mask): # x: [batch, seq_len, 768] lstm_out, _ self.lstm(x) # lstm_out 每个时刻的向量是 [hidden_size * 2]因为双向拼接 logits self.fc(lstm_out) # [batch, seq_len, num_tags] return logits参数说明hidden_size256表示单向LSTM隐层维度双向拼接后是512所以fc输入是hidden_size * 2。num_layers2代表两层LSTM堆叠能学习更高阶依赖但参数量和过拟合风险也随之增加。dropout0.5作用于LSTM层之间实际工程中还经常在fc之前再补一个dropout层。mask参数用于标注padding位置后续CRF计算和损失计算都需要靠它忽略无意义的补零位。2.3 CRF不让模型脱离实际标签规则强制全局最优BiLSTM输出的logits是每个字对每个标签的打分如果直接取最高分作为最终标签会出现“B-PER后面跟着I-ORG”这种非法序列因为人名的后面不可能直接接机构名的内部标签。CRF层在BiLSTM之上增加一个标签转移矩阵学习标签之间的合法性约束。例如“B-ORG”后面只能接“I-ORG”或“O”不能接“I-PER”。解码时不再逐字取最大而是用维特比算法寻找整条句子的全局最优路径。实际项目中常用torchcrf库或者参照CRF论文自己实现转移矩阵和log-sum-exp。一个典型的CRF接入方法是在模型forward里这样写import torch # 假设已有 crf 对象来自自实现或 torchcrf.CRF class NERModel(nn.Module): def __init__(self, num_tags): super().__init__() self.crf CRF(num_tags) # 封装好的CRF层 def forward(self, logits, tags, mask): # logits: [batch, seq_len, num_tags] # tags: [batch, seq_len], 合法位置为0~num_tags-1 # mask: [batch, seq_len], padding位置为0 loss self.crf(logits, tags, maskmask) decoded self.crf.decode(logits, maskmask) return loss, decoded这里loss返回的是负对数似然训练时希望它越小越好decoded是预测的标签序列。CRF的训练比普通softmax慢但换来的是实体边界更规整在中文NER任务上通常能提升2~4个百分点的F1值。2.4 为什么不直接用BERTsoftmaxCRF的必要性BERTsoftmax也能做序列标注每个位置独立分类但它不会显式建模标签依赖。比如“张三在腾讯工作”如果没有CRF约束模型可能把“腾讯”内部的“I-ORG”延续到“工作”两个字上甚至把“张”预测为“B-ORG”后后面的“三”无法确定是不是该接“I-PER”。CRF的好处是把“标签转移”也变成可学习的参数模型会发现“B-PER”后面如果跟着“I-PER”的比例远高于“I-ORG”从而在解码时更倾向合法路径。当然CRF也不是没有代价。解码阶段需要基于动态规划速度比逐位置argmax慢而且转移矩阵的初始化也影响收敛。如果你用torchcrf需要处理batch内不同长度序列的mask稍微粗心就会在padding位置报错。后续第5章会展开哪些坑最容易踩。3. 跑通这个Python项目的完整流程环境、数据、训练、预测3.1 环境准备python安装级别的依赖配置与bert模型下载这个项目基本基于Python 3.8以上版本建议创建独立虚拟环境避免污染系统Python。首先你需要一个能跑PyTorch的Python环境如果没有装Python先装Python再创建虚拟环境。环境搭建的命令如下python -m venv ner_env source ner_env/bin/activate # Windows 下用 ner_env\Scripts\activate pip install torch2.0.1 pip install transformers4.30.0 pip install seqeval pip install pandas tqdm参数说明torch2.0.1是一个稳定的版本如果要用GPU记得安装对应CUDA版本纯CPU运行也能训练但速度会慢很多。transformers4.30.0对应BERT模型加载接口的较稳定版本太旧可能不支持is_split_into_words参数太新可能引入不必要的依赖。seqeval是专门用来评测实体F1的库后面第6章会用到。下载BERT模型时transformers会尝试从Hugging Face官网拉取权重国内网络常常很慢或中断。常见做法是手动下载模型的config.json、pytorch_model.bin、vocab.txt三个文件放到本地目录然后用BertModel.from_pretrained(./bert-base-chinese/)指向本地路径。如果网络状况不好也可以配置HF_ENDPOINT环境变量使用镜像站点但这不涉及任何代理工具。3.2 看懂数据格式BIO标注与每个字的标签项目里的data目录通常包含train.txt、dev.txt、test.txt每行格式为“字符 标签”空行分隔不同句子。标签体系最常见的是BIO也有用BIOES的区别在于实体最后一个字用E还是I。下面是一个标准BIO标注样例张 B-PER 三 I-PER 在 O 北 B-LOC 京 I-LOC 上 O 班 O每个字符和标签之间用空格或制表符分隔。读取代码逻辑很简单但要注意一定按空行切分def load_data(path): sentences, labels [], [] tokens, tags [], [] with open(path, encodingutf-8) as f: for line in f: if line.strip() : if tokens: sentences.append(tokens) labels.append(tags) tokens, tags [], [] else: char, tag line.strip().split() tokens.append(char) tags.append(tag) return sentences, labels逻辑说明输入文件可能是UTF-8带BOM需要用encodingutf-8也可能需要utf-8-sigsplit默认按空白拆分如果标签里包含空格就不行了更稳妥的是split(\t)或split()。读取完成后需要建立标签到id的映射例如{O: 0, B-PER: 1, I-PER: 2, B-LOC: 3, I-LOC: 4}并同步建立id2label供解码时反向映射。3.3 训练模型从数据加载到损失函数训练阶段是整个项目的核心。模型由BERT、BiLSTM、CRF三部分串联输入是input_ids,attention_mask,labels。标签需要和BERT的token长度对齐中文一个汉字通常是一个token但数字或英文字符可能被切分得更细所以需要用word_ids做对齐后面避坑章节会详细讲。一个完整的训练循环大致如下from transformers import BertTokenizer, BertModel import torch.nn as nn from torch.utils.data import DataLoader, Dataset import torch class NERDataset(Dataset): def __init__(self, sentences, labels, tokenizer, label2id, max_len128): self.sentences sentences self.labels labels self.tokenizer tokenizer self.label2id label2id self.max_len max_len def __len__(self): return len(self.sentences) def __getitem__(self, idx): words self.sentences[idx] tags self.labels[idx] # 这里使用 is_split_into_wordsTrue 保证传入的是字列表 encoding self.tokenizer(words, is_split_into_wordsTrue, max_lengthself.max_len, truncationTrue, paddingmax_length, return_tensorspt) # 将标签对齐到token labels_ids [self.label2id[tag] for tag in tags] # 利用 word_ids 将每个token的标签映射到原始字padding位置设为-100 word_ids encoding.word_ids() aligned_labels [] previous_word_idx None for word_idx in word_ids: if word_idx is None: aligned_labels.append(-100) # padding和特殊token elif word_idx ! previous_word_idx: aligned_labels.append(labels_ids[word_idx]) else: aligned_labels.append(labels_ids[word_idx]) # 子词重复取同一标签 previous_word_idx word_idx return { input_ids: encoding[input_ids].squeeze(0), attention_mask: encoding[attention_mask].squeeze(0), labels: torch.tensor(aligned_labels) }然后定义训练函数def train_epoch(model, dataloader, optimizer, device): model.train() total_loss 0 for batch in dataloader: 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) optimizer.zero_grad() loss.backward() optimizer.step() total_loss loss.item() return total_loss / len(dataloader)参数说明paddingmax_length会把所有句子统一填充到128但填充位置标签为-100CRF计算时会通过mask忽略。word_ids是tokenizer返回的映射关系用于把原始字级别的标签复制到每个subword上。如果某个字被切成多个token这些token都共享同一个标签这是训练时不丢失标签信息的标准做法。3.4 预测与解码用CRF的维特比算法输出实体预测阶段不再需要标签只需要输入句子文本。模型forward返回decode结果再由id2label映射回标签名。一个容易忽略的点是BERT的is_split_into_wordsTrue与max_length联合使用时原始words长度不能超过max_len-2否则会被截断导致句子末尾实体被切掉。预测函数参考def predict_sentence(text, model, tokenizer, id2label, device, max_len128): model.eval() words list(text) encoding tokenizer(words, is_split_into_wordsTrue, max_lengthmax_len, truncationTrue, paddingmax_length, return_tensorspt) input_ids encoding[input_ids].to(device) attention_mask encoding[attention_mask].to(device) with torch.no_grad(): decode_ids model(input_ids, attention_mask) # [batch, seq_len] decoded decode_ids[0].tolist() # 去掉[CLS]和[SEP]保留原句子长度对应的部分 result_tags [id2label[i] for i in decoded[1:len(words)1]] return list(zip(words, result_tags))这里的decode_ids已经去掉了CRF维特比解码结果但长度还是padding后的长度。我们需要根据原始words长度截取有效部分。注意如果句子长度刚好为max_lendecoded[1:len(words)1]会越界所以通常设置max_len时留出2个位置给特殊token或者在截断前判断长度。4. 参数都在哪改让模型适配你自己的数据集4.1 文本长度与batch size显存和效果的平衡max_len决定每个样本最多编码多少个字BERT的自注意力复杂度是O(n²)128和512的显存消耗相差16倍。中文NER如果句子普遍不超过100字max_len128足够如果是法律条文或长病历建议先用统计看一下75分位长度再定max_len。千万不要无脑设512最后跑起来OOM又回来改数据。batch_size受显存限制一般Bert-BiLSTM-CRF在12GB显存上跑max_len128batch_size32可能刚好。如果显存不够可以把batch_size降到16同时增大gradient_accumulation_steps来模拟大batch。常见配置如下config { max_len: 128, batch_size: 32, gradient_accumulation_steps: 1, epochs: 5, lr_bert: 2e-5, lr_other: 1e-3, hidden_size: 256, num_layers: 2, dropout: 0.5, }4.2 学习率BERT层和BiLSTM层要不要分开BERT是预训练模型微调学习率如果太大会把已经学好的通用语义摧毁常见取值范围是2e-5到5e-5。而BiLSTM和CRF是随机初始化的层需要更大的学习率去收敛常见1e-3左右。项目里经常把参数分成两个组传给优化器optimizer torch.optim.AdamW([ {params: model.bert.parameters(), lr: 2e-5}, {params: model.bilstm.parameters(), lr: 1e-3}, {params: model.fc.parameters(), lr: 1e-3}, {params: model.crf.parameters(), lr: 1e-3}, ])参数说明这里lr_bert2e-5是BERT微调的常用起点如果数据集很小可以降到1e-5下游层学习率设置过高会导致loss震荡如果发现训练集loss不稳定可以把lr_other降到5e-4。4.3 BiLSTM隐层大小与dropout过拟合的闸门hidden_size控制BiLSTM的容量一般数据量在几千条时用128或256足够上万条可以考虑512。num_layers我通常设为2再多容易过拟合而且训练时间成倍增加。dropout是防过拟合的关键也影响收敛速度。一个小经验如果训练F1很高、验证F1很低先把dropout从0.5提到0.6如果两个都低先检查数据对齐不要盲目调dropout。4.4 训练轮数与早停什么时候收手中文NER微调通常3到5轮就能收敛因为BERT已经提供了很强的语义先验。但每轮epoch时间很长手动盯太多轮不现实所以项目里普遍加入早停机制如果连续2个epoch验证集F1没有提升就把学习率减半或直接停止并保存验证集最佳模型。保存模型时不要只存state_dict也要一并保存label2id.json和config.json否则预测时无法恢复标签映射。5. 避坑与常见问题我在这套模型上踩过的六个雷5.1 数据与标注的坑坑1BIO标签和BERT token不对齐导致预测全是O。现象训练loss能下降但用predict_sentence测试时所有输出都是O。原因最常见的是没有处理[CLS]和[SEP]把标签直接拼在原始字上导致标签序列和第1个token错位另外英文或数字会被BERT拆成多个subword原始字标签无法直接对上。解决用tokenizer.word_ids()做映射每个subword继承原始字的标签padding位置设为-100。预测时按1:len(words)1截取最好再用word_ids取每个原始字第一个subword的输出。坑2实体在句子间被切成两半。现象语料里一个人名“张三丰”刚好跨了两个换行第一行存“张”第二行存“三丰”模型永远学不会完整实体。原因数据预处理只按空行切分没有处理原始文档中断行导致实体断裂。解决切句前先按标点句号、分号分句再检查实体是否跨句。如果项目允许可以做成滑窗重叠让跨句实体至少一次在窗口内完整出现。坑3标注数据里非法序列太多CRF不收敛。现象训练时CRF loss降到某个值后不动验证F1在0附近。原因标注工具允许了“B-PER后面接I-ORG”这类非法标签CRF难以学习。解决写一个校验脚本扫描训练数据检查每个实体内部的标签连续性把非法标注找出来重新修正。不要指望CRF自己把脏数据纠正过来。5.2 模型与训练环境的坑坑4torch和transformers版本冲突BERT加载报错。现象from_pretrained时抛AttributeError: NoneType object has no attribute to。原因transformers新版本依赖较新的torch API老版本torch缺少某些方法。解决锁定版本组合比如transformers4.30.0配torch2.0.1。如果必须用新transformers就升级torch到对应版本不要混装。坑5Batch里padding位置参与CRF解码导致预测结果把填充位置也当成O。现象预测输出的标签数量比实际句子长后面多出一堆O。原因CRF decode时没有传入mask或者传了但padding标签用了0恰好是O导致padding位置也被解码成合法标签。解决在CRF层内把mask转换成bool解码时跳过mask0的位置。如果你自实现CRF务必在转移得分中加入mask惩罚。坑6BERT模型下载卡在中间from_pretrained一直重试。现象跑项目时终端卡在Downloading...半天不动最终连接失败。原因默认从国外服务器下载网络不稳定。解决在代码开头设置os.environ[HF_ENDPOINT] https://hf-mirror.com或者手动下载模型文件到本地并用本地路径加载。注意不要依赖任何需要额外配置的工具直接换成国内可直连的镜像源即可。6. 进阶用seqeval严格评测F1以及如何换到新领域当模型训练完别急着用准确率来评价要用seqeval计算实体级别的F1因为它是按实体的边界和类型统一匹配的。直接扁平化标签计算准确率会把“预测出一个完整实体”和“预测出了零散几个字”混淆。评测代码很简单from seqeval.metrics import classification_report y_true [[B-PER, I-PER, O], [O, B-LOC, I-LOC]] y_pred [[B-PER, I-PER, O], [O, B-LOC, I-LOC]] print(classification_report(y_true, y_pred))输出会给出每个实体类型的precision、recall、f1以及micro/macro平均。我一般只看实体级F1因为token级准确率往往虚高比如人名5个字预测对了3个token准确率看起来不错但实体级是0分。如果你想把这个项目迁移到新的领域比如从新闻迁移到法律文书要做的事很简单把标签集合换成你需要的实体类型重新标注数据然后继续用bert-base-chinese做初始化。如果新领域数据很少低于两千条建议冻结BERT层只训练BiLSTM和CRF让BERT参数不动防止小数据上过拟合。代码上就是把model.bert参数的requires_grad全部设为Falsefor param in model.bert.parameters(): param.requires_grad False这样训练时会快很多显存占用也低。如果数据量达到五千条以上再解冻BERT整模型微调。另外一个实用技巧是长文本处理法院文书经常超过500字但BERT窗口有限可以按句子拆分为多个样本保持实体完整性。拆分时不能让实体从中间断开所以拆分边界优先选择标点符号拆分后还要检查实体的首尾标签是否残缺。这套方案值不值得投入我的答案是值得。中文命名实体识别在舆情分析、金融信息抽取、医疗结构化里仍然是刚需BERT-BiLSTM-CRF虽然是三年前的标配但它的模块化设计让我后来换到RoBERTa、换到领域预训练模型时只需要改动数据加载和BERT加载部分BiLSTM和CRF层基本不用动。我最深的教训是头一次跑这个项目时没有验证标签对齐结果看到训练loss稳定下降心里还挺开心第二天用测试集一跑全是O那种心情就像推理半天的代码发现是黑匣子里的玄学。希望你从这篇笔记里能直接跳过那个通宵少走我走过的弯路让这套模型真正落到你的数据上。希望帮到你。本文还有配套的精品资源点击获取