简介这份资源是一套基于PyTorch的BERT-BiLSTM-CRF命名实体识别完整项目面向计算机相关专业做毕业设计的学生以及希望通过实战项目提升技能的中级开发者。项目难度中等代码经本地环境编译测试与多轮调试可直接运行学习。压缩包共34个文件约4.69MB包含6个py源码文件、6个txt数据文件、5个xml配置、2个ipynb笔记本及模型权重目录等覆盖数据预处理、模型构建、训练优化与评估测试全流程。技术方案上BERT层负责文本深度特征提取BiLSTM捕获上下文语义依赖CRF实现标签序列全局优化三者结合在命名实体识别任务中表现优越。已有68人学习。读者可获得完整源码、标准数据集、预训练模型配置及训练脚本便于快速复现实验、理解神经网络与统计模型结合的前沿思路并在此基础上完成自己的毕业设计或二次开发。1. 从一堆工单里认出「人名、地名、机构名」BERT-BiLSTM-CRF 到底在解决什么你手里有一批客服对话、合同文本或者医疗病历老板让你把里面的人名、公司名、药品名、金额全部自动抽出来人工标注一天只能标两千条模型跑一遍只要几秒。命名实体识别要解决的就是这件事给每个字打一个标签比如 B-PER、I-PER、O然后把连续的标签拼成实体。难点在于实体边界模糊、同一个词在不同语境下标签不同、长实体容易被截断。BERT-BiLSTM-CRF 这套组合是目前工业界最稳的基线方案之一BERT 负责把字变成带上下文语义的向量BiLSTM 负责捕捉前后序列依赖CRF 负责保证标签序列的合法性比如 I-PER 不能出现在 B-PER 前面。适合谁适合已经装好 PyTorch、想从零跑通一个 NER 系统、并且手里有几百到几千条标注数据的工程师。下面我按自己搭这套系统的顺序把环境、数据、模型、训练、踩坑、进阶验证全部拆开讲。2. 环境搭建与数据准备从 PyTorch 安装到标签对齐2.1 PyTorch 环境搭建版本对应与 GPU 检查热词里「pytorch安装教程超详细」「python和pytorch版本对应」被搜得最多说明这一步翻车的人不少。我一般用 conda 建一个干净环境Python 选 3.9 或 3.10PyTorch 选 2.0 以上。如果你用 7900xtx 或者 WSL注意 ROCm 和 CUDA 的适配差异Windows 原生对某些卡支持不好WSL 里反而更顺。先确认显卡驱动和 CUDA 版本再决定装哪个轮子。# 创建环境Python 版本不要超过 3.11很多 NLP 库还没跟上 conda create -n ner python3.10 -y conda activate ner # 查看 CUDA 版本决定 PyTorch 安装命令 nvidia-smi # 以 CUDA 11.8 为例去 PyTorch 官网复制对应命令 pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu118 # 验证 GPU 是否可用 python -c import torch; print(torch.__version__, torch.cuda.is_available())逻辑说明nvidia-smi右上角的 CUDA Version 是你驱动支持的最高版本不是必须完全一致向下兼容。torch.cuda.is_available()返回 False 时先检查是不是装了 CPU 版再检查驱动。参数上--index-url后面的 cu118 要换成你实际驱动对应的版本比如 cu121。装完再补transformers、seqeval、tqdmpip install transformers seqeval tqdm numpy pandas注意不要混用 conda 和 pip 装同一个包容易出玄学问题。如果公司内网慢配好镜像源再装。2.2 数据格式与标签体系BIO 还是 BIOES命名实体识别的数据一般是一行一个字加标签空行分隔句子。标签体系常见 BIO 和 BIOESBIOES 多了 E 和 S对实体边界更敏感但标注成本高。我一般先用 BIO够用。下面是一个三句的示例张 B-PER 三 I-PER 在 O 北 B-LOC 京 I-LOC 上 O 班 O 李 B-PER 四 I-PER 去 O 上 O 海 B-LOC读数据时要把句子和标签对齐别让空行把标签错位。用transformers的 tokenizer 时BERT 会把一个词切成多个 subword标签要对齐到第一个 subword其余设成 -100 忽略。这一步是血泪经验不对齐的话 loss 会降但 F1 不动。from transformers import BertTokenizer tokenizer BertTokenizer.from_pretrained(bert-base-chinese) def align_labels(words, labels, tag2id): # 先按字切中文 BERT 基本一字一 token tokens [] label_ids [] for word, label in zip(words, labels): sub_tokens tokenizer.tokenize(word) if not sub_tokens: sub_tokens [tokenizer.unk_token] tokens.extend(sub_tokens) # 第一个 subword 给真实标签其余给 -100 label_ids.append(tag2id[label]) label_ids.extend([-100] * (len(sub_tokens) - 1)) return tokens, label_ids参数说明tag2id是你自己维护的标签映射比如 {O:0, B-PER:1, I-PER:2, ...}。-100是 PyTorch CrossEntropyLoss 默认的 ignore_index不参与 loss 计算。如果你的数据里一个词就是一个字这段可以简化但保留它更通用。2.3 构建 Dataset 和 DataLoaderpadding 与 mask把对齐后的 token 转成 id加上 attention_mask再按 batch 做 padding。padding 到 batch 内最大长度就行不用全局最大省显存。import torch from torch.utils.data import Dataset, DataLoader class NERDataset(Dataset): def __init__(self, data, tokenizer, tag2id, max_len128): self.data data self.tokenizer tokenizer self.tag2id tag2id self.max_len max_len def __len__(self): return len(self.data) def __getitem__(self, idx): words, labels self.data[idx] tokens, label_ids align_labels(words, labels, self.tag2id) # 截断 tokens tokens[:self.max_len] label_ids label_ids[:self.max_len] input_ids self.tokenizer.convert_tokens_to_ids(tokens) attention_mask [1] * len(input_ids) return { input_ids: torch.tensor(input_ids), attention_mask: torch.tensor(attention_mask), labels: torch.tensor(label_ids) } def collate_fn(batch): max_len max(len(x[input_ids]) for x in batch) input_ids torch.zeros(len(batch), max_len, dtypetorch.long) attention_mask torch.zeros(len(batch), max_len, dtypetorch.long) labels torch.full((len(batch), max_len), -100, dtypetorch.long) for i, x in enumerate(batch): n len(x[input_ids]) input_ids[i, :n] x[input_ids] attention_mask[i, :n] x[attention_mask] labels[i, :n] x[labels] return {input_ids: input_ids, attention_mask: attention_mask, labels: labels}逻辑说明collate_fn里 padding 用 0labels 用 -100这样 loss 不会把 padding 算进去。max_len设 128 对大多数中文句子够用长文本可以设 256 或 512但显存会涨。注意attention_mask也要 padding 到同样长度否则 BERT 会报错。3. 模型实现BERT 输出接 BiLSTM 再接 CRF 的完整代码3.1 为什么是 BERT BiLSTM CRF 而不是单独 BERT单独 BERT 加一个线性分类头也能做 NER但标签之间的转移关系没被建模容易出现 I-PER 前面没有 B-PER 的非法序列。BiLSTM 在 BERT 输出的基础上再扫一遍序列捕捉更长的上下文依赖对长实体和嵌套实体有帮助。CRF 层则显式建模标签转移概率保证输出序列合法。三者叠加F1 通常比单独 BERT 高 1 到 3 个点代价是训练慢一点、显存多占一点。如果你的数据量很小几百条BiLSTM 可能过拟合可以只留 BERT CRF数据上千条以上这套组合很稳。3.2 BiLSTM 与 CRF 的 PyTorch 实现先写 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] 表示从 j 转移到 i 的分数 self.transitions nn.Parameter(torch.randn(num_tags, num_tags)) # 起始和结束转移 self.start_transitions nn.Parameter(torch.randn(num_tags)) self.end_transitions nn.Parameter(torch.randn(num_tags)) def forward(self, emissions, tags, mask): # emissions: (batch, seq_len, num_tags) # tags: (batch, seq_len) # mask: (batch, seq_len) 1 表示有效 return -self._log_likelihood(emissions, tags, mask).mean() def _log_likelihood(self, emissions, tags, mask): batch_size, seq_len, num_tags emissions.shape # 分子真实路径分数 score self.start_transitions[tags[:, 0]] score emissions[:, 0].gather(1, tags[:, 0].unsqueeze(1)).squeeze(1) for t in range(1, seq_len): score self.transitions[tags[:, t], tags[:, t-1]] * mask[:, t] score emissions[:, t].gather(1, tags[:, t].unsqueeze(1)).squeeze(1) * mask[:, t] # 最后一个有效位置加结束转移 last_tags tags.gather(1, mask.sum(1).long().unsqueeze(1) - 1).squeeze(1) score self.end_transitions[last_tags] # 分母所有路径分数用前向算法 return score - self._compute_normalizer(emissions, mask) def _compute_normalizer(self, emissions, mask): batch_size, seq_len, num_tags emissions.shape score self.start_transitions emissions[:, 0] for t in range(1, seq_len): score torch.logsumexp( score.unsqueeze(2) self.transitions.unsqueeze(0) emissions[:, t].unsqueeze(1), dim1 ) score self.end_transitions return torch.logsumexp(score, dim1) def decode(self, emissions, mask): # 维特比解码返回最佳路径 batch_size, seq_len, num_tags emissions.shape score self.start_transitions emissions[:, 0] history [] for t in range(1, seq_len): next_score score.unsqueeze(2) self.transitions.unsqueeze(0) emissions[:, t].unsqueeze(1) best_score, best_tag next_score.max(dim1) history.append(best_tag) score best_score score self.end_transitions best_last_tag score.argmax(dim1) best_tags [best_last_tag] for hist in reversed(history): best_last_tag hist.gather(1, best_last_tag.unsqueeze(1)).squeeze(1) best_tags.append(best_last_tag) best_tags.reverse() return torch.stack(best_tags, dim1)逻辑说明transitions[i][j]是从 j 到 i 的分数start_transitions和end_transitions分别建模句首句尾。_log_likelihood算真实路径分数减所有路径分数取负后最小化。_compute_normalizer用 logsumexp 做前向算法避免数值溢出。decode是维特比返回最佳标签序列。参数上num_tags等于你的标签总数比如 BIO 下 7 个标签就设 7。然后写主模型from transformers import BertModel class BertBiLSTMCRF(nn.Module): def __init__(self, bert_path, num_tags, lstm_hidden256, dropout0.3): super().__init__() self.bert BertModel.from_pretrained(bert_path) self.dropout nn.Dropout(dropout) self.bilstm nn.LSTM( input_sizeself.bert.config.hidden_size, hidden_sizelstm_hidden, num_layers1, bidirectionalTrue, batch_firstTrue ) self.classifier nn.Linear(lstm_hidden * 2, num_tags) self.crf CRF(num_tags) def forward(self, input_ids, attention_mask, labelsNone): outputs self.bert(input_idsinput_ids, attention_maskattention_mask) sequence_output outputs.last_hidden_state sequence_output self.dropout(sequence_output) lstm_output, _ self.bilstm(sequence_output) emissions self.classifier(lstm_output) if labels is not None: # 训练时返回 loss mask attention_mask.bool() loss self.crf(emissions, labels, mask) return loss else: # 推理时返回解码结果 mask attention_mask.bool() return self.crf.decode(emissions, mask)参数说明lstm_hidden设 256 是常见值显存不够可以降到 128。dropout设 0.3 防止过拟合数据少可以调到 0.5。num_layers1够用两层容易过拟合。bert_path用 bert-base-chinese热词里「bert 参数下载」搜得多第一次运行会自动下载内网的话提前把模型文件放到本地路径。3.3 训练循环与评估loss 下降但 F1 不动的排查训练循环里每轮跑完在验证集上算 F1用 seqeval 的 classification_report。下面是一个最小训练脚本from transformers import AdamW from seqeval.metrics import f1_score, classification_report device torch.device(cuda if torch.cuda.is_available() else cpu) model BertBiLSTMCRF(bert-base-chinese, num_tagslen(tag2id)).to(device) optimizer AdamW(model.parameters(), lr2e-5) for epoch in range(10): model.train() total_loss 0 for batch in train_loader: batch {k: v.to(device) for k, v in batch.items()} loss model(batch[input_ids], batch[attention_mask], batch[labels]) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) optimizer.step() optimizer.zero_grad() total_loss loss.item() # 验证 model.eval() preds, trues [], [] with torch.no_grad(): for batch in dev_loader: batch {k: v.to(device) for k, v in batch.items()} pred_tags model(batch[input_ids], batch[attention_mask]) # 把 id 转回标签忽略 -100 for pred, true, mask in zip(pred_tags, batch[labels], batch[attention_mask]): pred_list [id2tag[p.item()] for p, m in zip(pred, mask) if m.item() 1] true_list [id2tag[t.item()] for t, m in zip(true, mask) if m.item() 1 and t.item() ! -100] preds.append(pred_list) trues.append(true_list) print(fEpoch {epoch}, loss {total_loss:.4f}, F1 {f1_score(trues, preds):.4f})逻辑说明clip_grad_norm_防止梯度爆炸CRF 层容易出这个问题。lr2e-5是 BERT 微调的经典值太大 loss 会震荡太小收敛慢。如果 loss 下降但 F1 不动先检查标签对齐是不是错了再看验证集标签有没有越界最后看 CRF 的 mask 是不是把 padding 也算进去了。我踩过最坑的一次是attention_mask没传给 CRF导致 padding 位置的标签参与了转移计算F1 卡在 0.3 上不去。4. 避坑与排查命名实体识别训练中最容易翻车的 5 个点4.1 现象loss 降到 0.1 但 F1 只有 0.2原因标签对齐错误。BERT tokenizer 把词切成 subword 后你把标签给了最后一个 subword 或者所有 subword 都给了真实标签导致模型学到的边界和真实边界错位。解决按 2.2 的align_labels第一个 subword 给真实标签其余给 -100。验证方法打印一条样本的 tokens 和 label_ids人工核对。4.2 现象训练时报 CUDA out of memory原因max_len设太大或者 batch_size 太大或者 BiLSTM 的 hidden 太大。解决先把max_len从 512 降到 128batch_size 从 32 降到 8lstm_hidden从 256 降到 128。如果还不够用梯度累积模拟大 batch。另外torch.cuda.empty_cache()在每轮结束后调一次能释放缓存。4.3 现象验证集 F1 比训练集低 20 个点原因过拟合。数据量少、模型参数多、dropout 太小。解决把 dropout 调到 0.5BiLSTM 的 num_layers 保持 1加 weight_decay0.01早停。如果还不行冻结 BERT 前 6 层只微调后 6 层。数据增强也可以比如同义词替换、实体替换但别改变标签。4.4 现象CRF 解码出来的序列全是 O原因转移矩阵初始化不好或者学习率太大把 CRF 参数带飞了。解决把 CRF 的 transitions 初始化改成全零或者小随机数学习率降到 1e-5先冻结 BERT 训练几轮 CRF 和 BiLSTM再解冻一起微调。另外检查标签分布如果 O 占 95% 以上给非 O 标签加权重。4.5 现象推理时同一句话每次结果不一样原因模型还在 train 模式dropout 没关。解决推理前调model.eval()并且用torch.no_grad()包住。如果用了 BatchNorm 也要注意。这个坑很隐蔽因为训练时 loss 正常只有推理才暴露。5. 进阶验证与部署技巧从 F1 到线上可用的最后一步5.1 用混淆矩阵定位边界错误F1 只给一个总数看不出错在哪。我一般会画混淆矩阵看 B-PER 被预测成 I-PER 有多少I-LOC 被预测成 O 有多少。如果 B 和 I 混淆多说明边界学习不够可以加长 BiLSTM 或者换 BIOES 标签。如果实体被预测成 O说明召回不够检查训练数据里这类实体是不是太少。下面这段代码输出每个标签的 precision、recall、f1from seqeval.metrics import classification_report print(classification_report(trues, preds, digits4))参数说明trues和preds都是二维列表每个子列表是一句话的标签序列。digits4控制小数位。看报告时重点看 support 小的标签那些是低频实体F1 波动大。5.2 导出 ONNX 加速推理PyTorch 模型线上推理慢可以转 ONNX。注意 CRF 层的维特比解码在 ONNX 里不好实现我一般只导出 BERT BiLSTM 线性层CRF 解码用 Python 单独写或者用 ONNX Runtime 的自定义算子。导出命令torch.onnx.export( model, (dummy_input_ids, dummy_attention_mask), ner.onnx, input_names[input_ids, attention_mask], output_names[emissions], dynamic_axes{input_ids: {0: batch, 1: seq}, attention_mask: {0: batch, 1: seq}}, opset_version13 )逻辑说明dynamic_axes让 batch 和 seq_len 可变线上不用固定长度。opset_version13兼容性好。导出后用 onnxruntime 加载推理速度能快 2 到 3 倍。注意导出前调model.eval()并且把 CRF 的 decode 逻辑从 forward 里摘出来否则导出会报错。5.3 一个具体技巧用滑动窗口处理长文本BERT 最大长度 512长文本要切窗。我一般按 256 字切重叠 64 字每个窗单独推理然后合并结果。合并时重叠区域的标签取置信度高的或者简单取第一个窗的结果。这个技巧在合同、病历场景很实用能避免实体被切断。代码不复杂核心是维护一个全局标签数组按窗位置填进去。最后说个我自己的习惯每次跑新数据先拿 50 条人工过一遍看标签体系有没有歧义再开始训练。模型再强数据标错了也是白搭。这套 BERT-BiLSTM-CRF 我用了三年换过 BERT 变体、换过 CRF 实现但骨架没变过。希望帮到你。本文还有配套的精品资源点击获取