简介:这是一份基于Pytorch框架实现BERT+BiLSTM+CRF命名实体识别的毕业设计源码,面向NLP方向的学生和研究者,帮助解决文本中的人名、地名、机构名等实体自动抽取问题。项目完整覆盖数据准备、模型构建、训练与评估流程,采用预训练BERT提取上下文特征,BiLSTM进一步建模序列依赖,CRF层确保标签约束,可有效提升实体识别准确率。资源包内含99个文件,包括33个Python脚本、17个JSON配置、9个npz模型文件、4个日志及说明文档等,压缩包大小仅13.33MB,结构清晰便于修改扩展。除标准BERT+BiLSTM+CRF方案外,还提供BERT-Softmax、BERT-CRF、BiLSTM-CRF等多种对比模型,方便读者进行消融实验和效果对比。每个模型均配有数据加载、训练、评估脚本及README说明,可快速复现并应用于中文数据集(如CLUENER20)。已有632人浏览学习,适合作为毕业设计参考、课程项目或NLP入门练手资源。
1. 命名实体识别找不到现成基线?先把BERT+BiLSTM+CRF的四种变体跑通
中文命名实体识别(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_loader
2.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_len | 128 或 256 | 太长浪费显存,太短会截断实体 |
| batch_size | 16 或 32 | BERT微调时显存开销大 |
| learning_rate | BERT层2e-5,下游层1e-3 | 统一学习率容易训崩或欠拟合 |
| crf_lr | 0.01 或 0.001 | CRF转移矩阵参数量小,学习率可稍大 |
| hidden_size | 256 | BiLSTM隐层维度,双向拼接后是256 |
| epochs | 5 到 10 | BERT在小数据集上过拟合较快 |
| pretrained_model_path | pretrained_bert_models/bert-base-chinese | 本地路径或自动下载 |
需要注意,BERT层和下游层要用不同学习率,这是BERT系列微调的基本操作。BERT预训练参数已经收敛得比较好,如果给它一个过大的学习率,很容易把学到的语义破坏掉。常见的做法是对BERT参数使用2e-5到5e-5,对BiLSTM、全连接层和CRF层使用1e-3左右。
2.4 data_loader.py:BERT输入三个张量的对齐
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, truncation=True, padding="max_length", max_length=self.max_len, return_tensors="pt" ) # 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剖析:BERT+BiLSTM+CRF的关键实现
3.1 前向传播:从BERT输出到发射分数
model.py是整个项目的核心。基于PyTorch实现时,模型通常分成四个组件:BERT编码器、双向LSTM、全连接分类层、CRF层。前向传播的逻辑如下:
class BERTBiLSTMCrf(nn.Module): def __init__(self, bert_model, num_tags, hidden_size=256, dropout=0.1): super().__init__() self.bert = bert_model self.bilstm = nn.LSTM( input_size=768, # bert-base-chinese的hidden_size hidden_size=hidden_size // 2, # 双向拼接后正好是256 num_layers=1, batch_first=True, bidirectional=True ) self.dropout = nn.Dropout(dropout) self.fc = nn.Linear(hidden_size, num_tags) self.crf = CRF(num_tags, batch_first=True) def forward(self, input_ids, attention_mask): bert_outputs = self.bert( input_ids=input_ids, attention_mask=attention_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_size=256,单向维度会变成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_first=True保证了输入输出形状一致。另一个容易被忽略的点是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(dim=1) 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如何跑出可信F1
4.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_factor=1.0, end_factor=0.01, total_iters=num_steps )AdanW和SGD的区别在于AdamW对权重衰减做了解耦,在BERT微调场景下更稳定。学习率分组的原因,前面已经提到:BERT预训练参数已经收敛过,所以用小学习率微调;BiLSTM和CRF层是随机初始化,需要更大的学习率才能充分收敛。线性衰减调度器在训练后段逐步降低学习率,对序列标注任务的效果通常不错。
训练循环中还需要关注梯度裁剪。BERT系列模型在小批量数据上训练时经常出现梯度爆炸,常见做法是把梯度范数限制在5.0以内:
loss.backward() nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.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 F1
metrics.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层学习率或加入实体词典约束。
本文还有配套的精品资源,点击获取