简介这是一份基于BERTBiLSTMCRF实现中文命名实体识别的Python课程设计源码主要面向需要完成NLP方向课程设计、期末大作业或毕业设计的本专科学生。项目实现了从原始语料处理、字符编码、BERT向量表征、BiLSTM特征提取到CRF序列解码的完整NER流程提供开箱即用的训练与预测脚本无需修改任何配置即可运行并复现结果。该方案融合预训练语言模型与序列标注技术能有效提升中文文本中实体识别的准确性与鲁棒性。资源包共含18个文件其中9个Python脚本承担数据读取、模型定义、训练、评估与推理等核心功能2份Markdown文档介绍模型原理与使用说明3张PNG图片展示网络结构另包含日志、模型参数保存等目录压缩包大小仅134KB轻量精简、结构清晰。目前已有691人下载学习该项目的代码组织规范、注释到位能够帮助读者快速搭建中文NER实验环境同时也可作为课程设计答辩中的完整技术支撑是一份既适合学习又适合直接提交的高分项目。1. 中文命名实体识别课程设计为什么我劝你选 BERTBiLSTMCRF 这条技术栈如果你正在为 Python 课程设计选题发愁又不想做学生管理系统、贪吃蛇这类重复度极高的项目基于 BERTBiLSTMCRF 的中文命名实体识别是一个性价比很高的方向模型效果肉眼可见答辩时能讲清楚每一层在干什么而且源码工程化程度高不容易被评委问倒。所谓命名实体识别就是从一段中文文本里抽取出人名、地名、机构名这类具有特定含义的实体比如从“李华毕业于北京大学”中抽出“李华/人名”和“北京大学/机构名”。这条技术栈在今天依然是中文信息抽取任务的主力基线。BERT 负责把每个字的上下文语义编码成向量BiLSTM 在字向量的基础上捕捉序列依赖CRF 层则保证预测出的标签序列符合语法规则——比如“B-PER”后面不能直接跟“I-ORG”。三个组件各管一段分工明确这正是它能写进课程设计报告、也能在生产环境落地的原因。本文将带你从零构建一个可运行的中文 NER 工程覆盖数据准备、模型搭建、训练调参与踩坑记录你可以直接把它作为课程设计的主体代码框架。2. 拆解模型结构BERT、BiLSTM、CRF 各自在解决什么问题2.1 为什么是 BERT 而不是 Word2vec动态词向量的降维打击早期的中文 NER 系统大多基于 Word2vec 或 GloVe 预训练词向量。这类静态向量的致命缺陷是一个词在任何语境下只有一个向量表示。“苹果”在“苹果很好吃”和“苹果发布了新手机”中的向量完全相同模型只能靠下游结构去猜语境。而 BERT 这类预训练语言模型通过 Transformer 的双向编码结构让每个字的表示都融合了整句话的信息能根据上下文动态调整语义。具体到中文场景BERT 的输入是字级别的 token这与中文 NER 的标注单元天然对齐。你不需要额外做分词直接把句子按字切开送入模型即可。在实现时常用做法是加载bert-base-chinese预训练权重它的词表基于中文维基语料训练覆盖常用汉字和标点。这里有个关键参数是max_lenBERT 的最大输入长度是 512但考虑到显存开销和课程设计的数据规模128 或 256 通常足够。注意下载 BERT 权重时国内网络环境建议配置HF_ENDPOINT环境变量或使用国内镜像源否则很容易在from_pretrained阶段卡住。2.2 BiLSTM 的定位用双向语义补足 BERT 的序列建模有人会问BERT 本身已经是双向的为什么还要加 BiLSTM这个问题的答案是BERT 输出的每个 token 向量已经包含了上下文信息但它缺乏对标签序列的显式建模。BiLSTM 在这条链路中扮演的是“序列特征再提取器”的角色——它接收 BERT 的输出作为输入通过前向和后向两个 LSTM 分别捕捉从左到右、从右到左的依赖然后将两个方向的隐状态拼接。从实践角度看BiLSTM 层还带来一个工程上的好处降低微调 BERT 的参数量。课程设计的训练集通常只有几千到几万条直接微调 BERT 的所有层容易过拟合而加上 BiLSTM 后你可以冻结 BERT 的大部分底层参数只训练高层和 BiLSTM、CRF 层训练速度和稳定性都会提升。在代码实现中BiLSTM 的hidden_size一般取 128 或 256num_layers取 1 就够层数太多在小数据集上反而适得其反。2.3 CRF 的作用让标签序列不再“非法”如果只用 Softmax 对每个字独立分类模型很可能会输出类似“I-PER 开头”这样的非法序列——因为 Softmax 不考虑前一个标签是什么。CRF 层正是为解决这个问题设计的。它学习一个转移矩阵矩阵中的每个元素表示从一个标签转移到另一个标签的得分比如从“B-PER”转移到“I-PER”是合理转移得分应该高从“O”直接跳到“I-PER”则是不合理转移得分应该低。在推理阶段CRF 使用维特比算法在全部可能的标签序列中寻找全局最优路径而不是逐个字取最大概率。这个特性对实体边界判定至关重要尤其在人名和地名交织的复杂句子中CRF 能把边界错误率降低好几个百分点。需要说明的是PyTorch 官方没有内置 CRF 层常见做法是使用pytorch-crf库或自己实现一个课程设计中使用现成库即可在报告里说明其原理即可。3. 数据准备从原始文本到 BERT 可读的张量3.1 标注格式与数据集选择BIO 还是 BIOES中文 NER 的数据标注格式主流有两种BIO 和 BIOES。BIO 用 BBegin标记实体首字IInside标记实体中间字OOutside标记非实体BIOES 则在此基础上增加了 EEnd和 SSingle分别标记实体末字和单字实体。课程设计建议使用 BIOES原因是它在实体边界上的约束更强配合 CRF 效果更好而且在答辩时能多讲一个设计细节。数据集选择上最稳妥的是 CLUENER2020它是清华大学开源的细粒度中文 NER 数据集包含地址、书名、公司、游戏、政府、电影、姓名、组织、职位、景点 10 个类别训练集约一万条。这个数据集的标注质量高且是 json 格式解析简单适合课程设计直接使用。原始 json 每行是一条数据包含text字段和label字段label是字典键为实体类别值为[起始下标, 结束下标, 实体文本]的列表。3.2 一个干净的预处理脚本把 json 标注转成 BIOES 序列下面是我常用的数据预处理代码它完成三件事读取 CLUENER 格式的 json 文件、按字生成标签序列、把超出最大长度的样本做截断处理。import json def json_to_bioes(data_path, max_len128): 将 CLUENER 格式的 json 数据转换为 BIOES 标签序列 Args: data_path: json 文件路径 max_len: 最大序列长度 Returns: texts: 句子列表每个句子是字列表 labels: 标签列表与句子一一对应 texts, labels [], [] with open(data_path, r, encodingutf-8) as f: for line in f: item json.loads(line.strip()) text list(item[text])[:max_len] label [O] * len(text) for entity_type, entities in item[label].items(): for start, end, _ in entities: # 截断到 max_len 范围内的实体才保留 if start max_len or end max_len: continue if end - start 1: label[start] fS-{entity_type} else: label[start] fB-{entity_type} for i in range(start 1, end): label[i] fI-{entity_type} label[end - 1] fE-{entity_type} texts.append(text) labels.append(label) return texts, labels这段代码的重点在于边界处理。end - start 1表示实体只有一个字直接标为S-否则标记B-开头、E-结尾、中间全为I-。注意循环里对max_len的检查如果实体起始下标超出截断范围直接跳过如果实体跨越截断点则丢弃后段这是为了避免出现B-开头但没有对应E-结尾的残缺标签否则 CRF 层在训练时会报错或学出错误转移。3.3 构建 DataLoaderBERT Tokenizer 与标签对齐的经典坑BERT 使用 WordPiece 分词中文虽然大多是单字成 token但遇到特殊符号或英文单词时一个字可能被拆成多个子 token。这会导致 token 序列长度与标签序列长度不一致需要在编码过程中把标签对齐到第一个子 token 上。from torch.utils.data import Dataset, DataLoader from transformers import BertTokenizer class NERDataset(Dataset): def __init__(self, texts, labels, tokenizer, max_len128, tag2idxNone): self.texts texts self.labels labels self.tokenizer tokenizer self.max_len max_len self.tag2idx tag2idx def __len__(self): return len(self.texts) def __getitem__(self, idx): text self.texts[idx] label self.labels[idx] # 编码时设置 add_special_tokensTrue会自动添加 [CLS] 和 [SEP] encoding self.tokenizer( text, is_split_into_wordsTrue, # 传入的是字列表 truncationTrue, paddingmax_length, max_lengthself.max_len, return_tensorspt ) # 将标签对齐到 token 序列 word_ids encoding.word_ids() # 每个 token 对应的原始字下标 aligned_labels [] previous_word_idx None for word_idx in word_ids: if word_idx is None: aligned_labels.append(-100) # 特殊 token 不计算 loss else: aligned_labels.append(self.tag2idx[labels[word_idx]]) previous_word_idx word_idx return { input_ids: encoding[input_ids].squeeze(0), attention_mask: encoding[attention_mask].squeeze(0), token_type_ids: encoding[token_type_ids].squeeze(0), labels: torch.tensor(aligned_labels) }这里的关键是word_ids()方法它返回每个 token 对应的原始文本下标。对于[CLS]和[SEP]以及 padding 部分word_idx为None此时标签设为-100在计算损失时忽略这些位置。is_split_into_wordsTrue告诉 tokenizer 传入的 text 已经是字列表不需要再分词。整个对齐逻辑是 NER 代码里最容易出错的地方建议在写完这段后打印几条样本人工检查对应关系。4. 模型搭建与训练循环让三个组件协同工作4.1 定义 BERTBiLSTMCRF 模型类模型类的构造函数接收 BERT 预训练模型名称、标签数量、BiLSTM 隐层维度等参数forward方法按 BERT → BiLSTM → CRF 的顺序计算。训练阶段返回损失推理阶段返回预测标签序列。import torch import torch.nn as nn from transformers import BertModel from torchcrf import CRF class BertBiLSTMCRF(nn.Module): def __init__(self, bert_pretrainedbert-base-chinese, num_tags31, lstm_hidden256): super().__init__() self.bert BertModel.from_pretrained(bert_pretrained) self.lstm nn.LSTM( input_sizeself.bert.config.hidden_size, # 768 hidden_sizelstm_hidden, num_layers1, bidirectionalTrue, batch_firstTrue ) self.fc nn.Linear(lstm_hidden * 2, num_tags) # 双向拼接 self.crf CRF(num_tags, batch_firstTrue) def forward(self, input_ids, attention_mask, token_type_ids, labelsNone): # BERT 编码 outputs self.bert( input_idsinput_ids, attention_maskattention_mask, token_type_idstoken_type_ids ) sequence_output outputs.last_hidden_state # (batch, seq_len, 768) # BiLSTM 编码 lstm_out, _ self.lstm(sequence_output) # (batch, seq_len, 512) # 线性层映射到标签空间 emissions self.fc(lstm_out) # (batch, seq_len, num_tags) if labels is not None: # 训练阶段计算 CRF 损失 # mask 为 1 的位置参与计算0 的位置忽略 mask attention_mask.bool() log_likelihood self.crf(emissions, labels, maskmask) return -log_likelihood else: # 推理阶段维特比解码 mask attention_mask.bool() predictions self.crf.decode(emissions, maskmask) return predictionstorchcrf库的CRF层返回的是对数似然PyTorch 的优化器默认做梯度下降所以取负值作为 loss。num_tags的取值取决于你的标签类别集合BIOES 模式下 10 类实体对应 41 个标签每类 4 个 1 个 O如果只用 BIO 模式则是 21 个。lstm_hidden取 256 是兼顾效果和显存的常用值隐层越大拟合能力越强但过拟合风险也同步上升。4.2 训练循环的关键细节梯度裁剪与学习率策略训练环节有四个参数值得特别留意。首先是学习率BERT 层的学习率应远小于 BiLSTM 和 CRF 层常用做法是为 BERT 设置 2e-5为下游层设置 1e-3 到 5e-3这样可以避免微调过程中破坏 BERT 的预训练知识。其次是梯度裁剪BiLSTM 在长序列上容易产生梯度爆炸clip_grad_norm_设为 5.0 是安全值。第三是 batch sizeBERT 对显存占用高课程设计环境通常只有 6-8GB 显存batch size 取 8 或 16 即可。第四是早停机制每轮在验证集上计算 F1 分数连续三轮不提升就停止训练。from transformers import AdamW from seqeval.metrics import classification_report optimizer AdamW([ {params: model.bert.parameters(), lr: 2e-5}, {params: model.lstm.parameters(), lr: 1e-3}, {params: model.fc.parameters(), lr: 1e-3}, {params: model.crf.parameters(), lr: 1e-3}, ]) best_f1 0.0 for epoch in range(epochs): model.train() for batch in train_loader: input_ids batch[input_ids].to(device) attention_mask batch[attention_mask].to(device) token_type_ids batch[token_type_ids].to(device) labels batch[labels].to(device) loss model(input_ids, attention_mask, token_type_ids, labels) loss.backward() nn.utils.clip_grad_norm_(model.parameters(), max_norm5.0) optimizer.step() optimizer.zero_grad() # 验证 model.eval() predictions, true_labels [], [] with torch.no_grad(): for batch in dev_loader: input_ids batch[input_ids].to(device) attention_mask batch[attention_mask].to(device) token_type_ids batch[token_type_ids].to(device) labels batch[labels].to(device) preds model(input_ids, attention_mask, token_type_ids) # 还原真实标签去掉 -100 的填充位 for pred, label in zip(preds, labels): predictions.append(pred) true_label [label[i].item() for i in range(len(label)) if label[i].item() ! -100] true_labels.append(true_label) f1 classification_report(true_labels, predictions, output_dictTrue)[macro avg][f1-score] if f1 best_f1: best_f1 f1 torch.save(model.state_dict(), best_model.pt)验证阶段需要注意CRF 层decode返回的是变长列表每句话的长度不同而真实标签labels是固定max_len的张量。还原真实标签时我这里的写法只去掉了-100的 token但它默认了预测结果与真实标签一一对应。稳妥做法是将 labels 张量截断到预测列表的长度再比较。4.3 预测函数让新文本走进模型课程设计通常需要一个交互式预测函数输入一句中文输出实体列表。这个函数要和训练时的数据预处理保持完全一致否则预测结果会莫名其妙地变差。def predict(text, model, tokenizer, idx2tag, device, max_len128): model.eval() tokens list(text)[:max_len] encoding tokenizer( tokens, is_split_into_wordsTrue, truncationTrue, paddingmax_length, max_lengthmax_len, return_tensorspt ) input_ids encoding[input_ids].to(device) attention_mask encoding[attention_mask].to(device) token_type_ids encoding[token_type_ids].to(device) with torch.no_grad(): preds model(input_ids, attention_mask, token_type_ids)[0] # 将 token 预测映射回原始字并抽取实体 word_ids encoding.word_ids() entities [] current_entity None for token_idx, word_idx in enumerate(word_ids): if word_idx is None or token_idx len(preds): continue tag idx2tag[preds[token_idx]] if tag.startswith(B-): if current_entity: entities.append(current_entity) current_entity {type: tag[2:], start: word_idx, text: tokens[word_idx]} elif tag.startswith(I-) and current_entity and current_entity[type] tag[2:]: current_entity[text] tokens[word_idx] elif tag.startswith(E-) and current_entity and current_entity[type] tag[2:]: current_entity[text] tokens[word_idx] entities.append(current_entity) current_entity None elif tag S-: entities.append({type: tag[2:], start: word_idx, text: tokens[word_idx]}) return entities这里有一个隐藏的映射问题tokenizer返回的 token 序列长度比preds长因为 padding 到max_len的部分 CRF 也输出了标签。我的处理方式是只遍历len(preds)范围内的 token超出部分的预测直接忽略。抽取实体时注意I-标签要判断是否与当前实体类型一致防止跨类型串接。5. 训练避坑跑通中文 NER 实训项目最常踩的五个坑5.1 标签对齐错误导致 loss 异常高或训练不收敛现象训练 loss 一直在 5 以上降不下去验证集 F1 在 0.1 左右徘徊。原因BERT 的 WordPiece 分词把部分字拆成了多个子 token导致 token 序列和标签序列长度不匹配标签错位。 这一步出错方程会把错误的标签分配给正确的 token。解决在构建数据集时用encoding.word_ids()建立 token 到原始字下标的映射并在每个 token 上对齐标签。写完后手动打印input_ids对应的字和labels对应的标签逐条比对边界。5.2 显存不足batch size 调到 4 还是崩现象运行训练脚本时报CUDA out of memory即使把 batch size 降到 4 依然溢出。原因BERT 的序列长度设置为 512且没有开梯度检查点。BERT 在序列长度为 512 时单条样本的特征图就非常大。解决先把max_len降到 128观察显存占用。如果还不够在from_pretrained时启用梯度检查点model.bert.gradient_checkpointing_enable()。如果显存只有 4GB考虑使用bert-base-chinese的蒸馏版或直接把 batch size 设为 1 配合梯度累积。5.3 训练集很小F1 却虚高没有做严格的实体级评估现象训练集上 F1 达到 0.95但手动输入几句测试文本实体抽取得一塌糊涂。原因直接用sklearn的accuracy_score评估标签准确率。这个指标对“O 标签占绝大多数”的数据集天然虚高——即使所有实体都没抽出来只要 O 标对了准确率也有 90% 以上。解决改用seqeval库它按实体级别计算精确率、召回率和 F1只有实体边界和类型完全正确才算预测对。安装命令是pip install seqeval在验证循环中整理出predictions和true_labels后直接调用classification_report。5.4 模型在验证集上过拟合早停回调写在哪现象训练 log 显示训练 loss 持续下降验证集 F1 从第 5 轮开始不再提升甚至下降。原因训练轮数设置过大且没有早停机制。课程设计的小数据集通常 3-5 轮就能收敛10 轮以上必然过拟合。解决在训练循环中维护一个best_f1变量连续三轮验证集 F1 不创新高就break同时保存验证集 F1 最高的模型权重。推理时加载best_model.pt不是最后一个 epoch 的模型。5.5 复现不一致同一份代码两次训练结果不同现象相同的数据、相同的超参数两次训练出来的 F1 差了 2 个点以上。原因没有设置随机种子PyTorch、NumPy、Python 内置random三个库的随机源彼此独立。解决在main函数开头设置seed 42然后依次执行random.seed(seed)、np.random.seed(seed)、torch.manual_seed(seed)如果用了 CUDA 再加一行torch.cuda.manual_seed_all(seed)。这能让你的实验可复现答辩时也能理直气壮地说“结果稳定”。6. 进阶优化让模型效果更进一步的三个方向当前面的流程已经稳定跑通、F1 达到 0.85 以上后有三个进阶方向值得投入。第一个是标签体系升级如果你使用的是 BIO 标签改成 BIOES 可以提升约 0.5-1 个 F1 点。原因在于 E 和 S 标签为 CRF 提供了更强的边界约束转移矩阵能学到“B-PER 后面必须接 I-PER 或 E-PER”这类规则。改造方式只需要改预处理函数模型层无需变动。第二个是引入对抗训练 FGMFast Gradient Method。在训练时对 BERT 的 embedding 施加微小的扰动让模型对噪声更鲁棒。实现方式是在计算 loss 前保存 embedding 参数反向传播后加上扰动再算一次 loss最后恢复原始参数。这个方法在中文 NER 上通常能带来 1-2 个点的提升而且代码改动量小适合写进课程设计的“创新点”章节。第三个是尝试用bert-base-chinese的 Larger 版本或 RoBERTa-wwm-ext 替换底层预训练模型。全词掩码预训练策略对中文更友好因为中文词边界信息对实体识别很重要。不过要注意显存占用和推理速度的成倍增加如果课程设计的运行环境只是个人笔记本建议保留 BERT base 作为最终方案。最后分享一个我在这个项目上悟出来的习惯把所有超参数学习率、batch size、max_len、随机种子写在一个config.py里而不是散落在各处每个实验做完后把config和结果 F1 一并记录下来。你会发现很多“玄学”的翻车现场其实都是某次参数被静默改动导致的。这个好习惯能帮你节省大量排查时间也希望这篇实战笔记能帮你在课程设计中少踩几个坑顺利把模型跑起来。本文还有配套的精品资源点击获取
