
简介这是一份基于Pytorch框架实现BERTBiLSTMCRF命名实体识别的毕业设计源码面向NLP方向的学生和研究者帮助解决文本中的人名、地名、机构名等实体自动抽取问题。项目完整覆盖数据准备、模型构建、训练与评估流程采用预训练BERT提取上下文特征BiLSTM进一步建模序列依赖CRF层确保标签约束可有效提升实体识别准确率。资源包内含99个文件包括33个Python脚本、17个JSON配置、9个npz模型文件、4个日志及说明文档等压缩包大小仅13.33MB结构清晰便于修改扩展。除标准BERTBiLSTMCRF方案外还提供BERT-Softmax、BERT-CRF、BiLSTM-CRF等多种对比模型方便读者进行消融实验和效果对比。每个模型均配有数据加载、训练、评估脚本及README说明可快速复现并应用于中文数据集如CLUENER20。已有632人浏览学习适合作为毕业设计参考、课程项目或NLP入门练手资源。1. 命名实体识别找不到现成基线先把BERTBiLSTMCRF的四种变体跑通中文命名实体识别NER最磨人的不是模型结构而是“对照实验怎么设计”。这套毕业设计源码没有把BERT、BiLSTM、CRF捆成一个黑盒而是把BERT-Softmax、BERT-CRF、BERT-LSTM-CRF、BiLSTM-CRF四个目录整整齐齐放在同一个CLUENER20数据管线里共用一套config.py、data_loader.py和metrics.py。对要完成毕业设计或课程设计的人来说它的价值在于你不需要从头拼代码只需要改几个配置参数就能在experiments目录里横向对比“BERT到底贡献了什么、BiLSTM和CRF各补了什么”而不仅仅是跑出一个孤立的F1指标。适合PyTorch基础过关、想快速搭出NER基线并输出完整实验报告的读者。2. 目录与数据管线CLUENER20如何进入data_loader2.1 四套目录先解决“实验对比”问题解压后的代码按模型变体拆成了四个平行目录BERT-Softmax、BERT-CRF、BERT-LSTM-CRF、BiLSTM-CRF。每个目录内部结构几乎一致都包含model.py、train.py、run.py、data_process.py、data_loader.py、config.py、utils.py和metrics.py另外还有pretrained_bert_models目录和experiments目录。这种组织方式看似重复实际很有用做毕业论文时评审最常问的问题是“为什么你的模型比基线好”而你只需要把四个目录的experiments结果贴出来就能清楚说明每一步改动带来的收益。每个目录的功能文件分工如下文件职责在实验流程中的位置config.py超参数、路径、预训练模型名称先读这里再决定后续data_process.py将CLUENER20原始数据集转换成训练样本和标签序列第一次运行前执行data_loader.py构造Dataset和DataLoader产出token_ids、attention_mask、labels训练和推理共用model.py定义模型结构被train.py和run.py调用train.py训练循环、验证、模型保存执行训练run.py加载已保存模型做推理或评估执行测试metrics.py计算precision、recall、F1训练结束后调用这里有个容易被忽略的细节CLUENER20数据集本身是实体级标注其中实体类型包括地址、书名、公司、游戏、政府、电影、姓名、组织、职位、场景等原始数据常见格式是把每个实体标成start/end偏移量。而模型训练需要的是token级别的标签序列所以data_process.py的核心工作就是把偏移量转换成BIO序列。常见做法是给每个token分配一个标签实体的第一个token标记为B-XXX后续token标记为I-XXX非实体标记为O。2.2 data_process.py从原始标注到BIO标签序列以CLUENER20常见的JSON结构为例一条样本通常会包含text字段和label字段label里是实体类型到偏移量的映射。data_process.py的转换逻辑一般如下def convert_to_bio(text, entities): # entities: {address: [[start, end], ...]} labels [O] * len(text) for entity_type, spans in entities.items(): for start, end in spans: if end start or end len(text): continue labels[start] B- entity_type for idx in range(start 1, end): labels[idx] I- entity_type return list(text), labels def build_label2id(entity_types): label2id {O: 0} for entity_type in entity_types: label2id[B- entity_type] len(label2id) label2id[I- entity_type] len(label2id) return label2id这段代码的逻辑是先把每个字初始化为O然后遍历实体span把起点位置改成B-XXX后续连续位置改成I-XXX。build_label2id返回的字典会存成一个label2id.json或直接被config引用。需要特别说明的是CLUENER20的end到底是开区间还是闭区间不同下载版本不一致所以data_process.py通常会做一个边界校验——这也是训练时标签错位最常见的来源。如果转换完的标签数量不等于文本长度后续data_loader.py必然报错。2.3 config.py被修改最多的几个参数config.py决定了一组实验的走向。以下是我在实际使用中会优先检查的参数参数常见取值影响max_seq_len128 或 256太长浪费显存太短会截断实体batch_size16 或 32BERT微调时显存开销大learning_rateBERT层2e-5下游层1e-3统一学习率容易训崩或欠拟合crf_lr0.01 或 0.001CRF转移矩阵参数量小学习率可稍大hidden_size256BiLSTM隐层维度双向拼接后是256epochs5 到 10BERT在小数据集上过拟合较快pretrained_model_pathpretrained_bert_models/bert-base-chinese本地路径或自动下载需要注意BERT层和下游层要用不同学习率这是BERT系列微调的基本操作。BERT预训练参数已经收敛得比较好如果给它一个过大的学习率很容易把学到的语义破坏掉。常见的做法是对BERT参数使用2e-5到5e-5对BiLSTM、全连接层和CRF层使用1e-3左右。2.4 data_loader.pyBERT输入三个张量的对齐data_loader.py需要同时产出token_ids、attention_mask和labels并且labels必须跟着BERT分词结果重新对齐。中文BERT使用WordPiece分词一个汉字通常会被保留为一个token但特殊符号和空格可能被拆掉导致labels和token_ids长度不一致。代码处理逻辑通常如下class NERDataset(Dataset): def __init__(self, examples, tokenizer, label2id, max_len): self.examples examples self.tokenizer tokenizer self.label2id label2id self.max_len max_len def __getitem__(self, idx): text, labels self.examples[idx] # 这里用tokenizer的偏移量对齐方式避免手动切分错误 encoding self.tokenizer( text, truncationTrue, paddingmax_length, max_lengthself.max_len, return_tensorspt ) # labels需要重新映射到bert切分后的token token_labels align_labels(text, labels, self.tokenizer, self.label2id) return { input_ids: encoding[input_ids].squeeze(0), attention_mask: encoding[attention_mask].squeeze(0), labels: token_labels }这段代码的关键是align_labels函数。常见做法是利用tokenizer返回的offset_mapping参数把每个token映射回原文本的位置再构造对应的标签序列。另一个容易踩坑的点是padding后标签也要补上-100因为PyTorch的CrossEntropyLoss会把-100忽略掉CRF层的mask也需要同步处理。如果这里不处理计算loss时padding位置会干扰训练。3. model.py剖析BERTBiLSTMCRF的关键实现3.1 前向传播从BERT输出到发射分数model.py是整个项目的核心。基于PyTorch实现时模型通常分成四个组件BERT编码器、双向LSTM、全连接分类层、CRF层。前向传播的逻辑如下class BERTBiLSTMCrf(nn.Module): def __init__(self, bert_model, num_tags, hidden_size256, dropout0.1): super().__init__() self.bert bert_model self.bilstm nn.LSTM( input_size768, # bert-base-chinese的hidden_size hidden_sizehidden_size // 2, # 双向拼接后正好是256 num_layers1, batch_firstTrue, bidirectionalTrue ) self.dropout nn.Dropout(dropout) self.fc nn.Linear(hidden_size, num_tags) self.crf CRF(num_tags, batch_firstTrue) def forward(self, input_ids, attention_mask): bert_outputs self.bert( input_idsinput_ids, attention_maskattention_mask ).last_hidden_state lstm_outputs, _ self.bilstm(bert_outputs) lstm_outputs self.dropout(lstm_outputs) emissions self.fc(lstm_outputs) return emissions, attention_mask这里有两个必须说明的细节。第一hidden_size // 2是因为双向LSTM会把前向和后向的隐状态拼接单向输出维度是128拼接后才是256如果直接写hidden_size256单向维度会变成256、拼接后变成512和后面的nn.Linear(256, num_tags)维度对不上。第二emissions的形状是(batch_size, seq_len, num_tags)它表示每个token属于每个标签的分数但它还没有考虑标签之间的转移关系这个关系由CRF层补齐。3.2 BiLSTM的拼接方式与dropout位置BiLSTM在这里的作用是对BERT输出的上下文做二次特征提取。BERT已经具有很强的上下文建模能力那为什么还要加BiLSTM常见的解释是BiLSTM能进一步提取局部序列特征尤其是在标注一致性上可以修正BERT某些token的孤立预测。BiLSTM的输入是BERT的last_hidden_state即每个token的768维向量。前向LSTM从序列头走到尾后向LSTM从尾走到头每个位置的输出拼接成hidden_size维。关键是在拼接维度上PyTorch的batch_firstTrue保证了输入输出形状一致。另一个容易被忽略的点是dropout的位置BiLSTM的output之后、全连接层之前这个位置能让CRF看到的发射分数更平滑尽量避免过拟合。3.3 CRF层的转移矩阵与解码逻辑CRF层和普通Softmax分类的本质区别是Softmax假设每个位置独立预测标签而CRF显式建模相邻标签之间的转移概率。在模型内部CRF会维护一个num_tags x num_tags的转移矩阵其中transition[i][j]表示从标签i转移到标签j的得分。训练时CRF的损失函数是负对数似然利用前向算法在全部可能标签序列路径上求对数归一化因子。实现上常见做法是直接用torchcrf库如果你希望自己实现核心的viterbi解码逻辑大致如下def viterbi_decode(emissions, mask, transition, start_trans, end_trans): batch_size, seq_len, num_tags emissions.shape score start_trans emissions[:, 0, :] backpointers [] for t in range(1, seq_len): # next_score[batch, j] max_i(score[batch, i] transition[i, j]) next_score score.unsqueeze(2) transition.unsqueeze(0) best_score, best_idx next_score.max(dim1) score best_score emissions[:, t, :] backpointers.append(best_idx) # 最后加上end_trans回溯best_idx得到最优标签序列 return best_path这段代码在训练中不会用到只在run.py推理时使用。训练时则使用crf(emissions, tags, mask)计算负对数似然损失再用crf.decode(emissions, mask)得到预测标签序列。实际使用中CRF层的num_tags必须包含全部标签种类即label2id的字段数通常数十个左右。4. 训练与指标train.py与run.py如何跑出可信F14.1 优化器分组与学习率调度train.py里最值得研究的不是训练循环本身而是优化器参数的设置。推荐使用AdamW优化器并且把参数分成不同的组给不同层分配不同学习率bert_params set(model.bert.parameters()) other_params [p for p in model.parameters() if p not in bert_params] optimizer torch.optim.AdamW([ {params: model.bert.parameters(), lr: 2e-5}, {params: other_params, lr: 1e-3} ]) scheduler torch.optim.lr_scheduler.LinearLR( optimizer, start_factor1.0, end_factor0.01, total_itersnum_steps )AdanW和SGD的区别在于AdamW对权重衰减做了解耦在BERT微调场景下更稳定。学习率分组的原因前面已经提到BERT预训练参数已经收敛过所以用小学习率微调BiLSTM和CRF层是随机初始化需要更大的学习率才能充分收敛。线性衰减调度器在训练后段逐步降低学习率对序列标注任务的效果通常不错。训练循环中还需要关注梯度裁剪。BERT系列模型在小批量数据上训练时经常出现梯度爆炸常见做法是把梯度范数限制在5.0以内loss.backward() nn.utils.clip_grad_norm_(model.parameters(), max_norm5.0) optimizer.step() scheduler.step()4.2 train.py里的三个检查点训练过程中我一般会重点关注三个环节。第一验证集loss和训练集loss是否同步下降如果训练集loss下降但验证集loss上升说明过拟合应当减小epoch或增加dropout。第二模型保存策略一般只保存验证集F1最高的模型而不是最后一个epoch的模型。第三训练日志里应当记录每个epoch的precision、recall、F1而不仅仅是loss。4.3 metrics.py先看micro F1还是macro F1metrics.py决定你最终报告里的核心数字。NER任务中F1的计算方式有两种主要口径计算方式含义适用场景Micro F1把所有类型合并计算TP、FP、FN更关注整体预测质量Macro F1先算每个实体类型的F1再取平均更关注低频实体是否被照顾到在CLUENER20这种少数类实体偏多的数据上建议同时报告两个值。常见的做法是metrics.py里维护一个classification_report字典输出每个实体类型的precision、recall、F1和整体均值。答辩时如果评审质疑这个改动到底是否有效直接用两个模型在同一类型的F1对比表来回答。另一个值得注意的是run.py的运行方式。常见用法是传入一个--model_dir参数指向训练好的目录然后读取config.py中的标签映射初始化模型后执行维特比解码。解码时不要直接使用torch.max(emissions, dim-1)因为这种硬标签输出忽略了标签转移约束可能出现B-I序列不合法的问题必须走CRF的decode函数。5. 四目录横向对比答辩演示与微调技巧5.1 四组实验结果在答辩里怎么组织把BERT-Softmax、BERT-CRF、BERT-LSTM-CRF、BiLSTM-CRF四个目录跑完后最容易出效果的是下面这张对比表模型核心差异预期效果趋势BiLSTM-CRF无预训练语言模型依赖随机初始化embedding基线最低收敛慢BERT-Softmax使用BERT但不建模标签转移明显提升但会输出非法标签序列BERT-CRF在BERT上直接加CRF比Softmax稳定解决非法转移BERT-LSTM-CRF在BERT和CRF之间插入BiLSTM在长句和复杂实体上更稳答辩演示时建议把某个实体类型的漏报样例做成case demo图片展示例如把“武汉市”这类地名标注中Softmax输出的“B-gov I-gov B-gov”非法转移拿出来对比会非常直观。CLUENER20里有政府、地址、公司这类边界模糊的实体很容易找到这样的案例。5.2 三个立竿见影的微调技巧第一个技巧是冻结BERT前几层。BERT低层编码的是词法和句法信息与NER任务相关性不大。常见做法是设置model.bert.embeddings.requires_grad_(False)并对前四层Transformer层冻结参数这样可以在GPU显存不足时降低训练开销同时避免低层过拟合。第二个技巧是给BiLSTM层的初始隐层状态清零并在每个batch开始时手动调用model.bilstm.flatten_parameters()在使用多GPU训练时这个操作能避免RNN参数不连续导致的性能下降。第三个技巧是验证集上看不完全匹配的“字符边界错误”案例比如把“北京百度公司”预测成“B-gov I-gov I-gov”而不是“B-company I-company”这类错误说明BERT上下文表征已经学好但转移矩阵还需要更多数据此时可以考虑增大CRF层学习率或加入实体词典约束。本文还有配套的精品资源点击获取