资讯动态

BERT+BiLSTM+CRF实现命名实体识别:Pytorch完整项目解析

发布时间:2026/9/11 13:14:37 来源:尧图企业网站定制
简介面向毕业设计、期末大作业和课程设计场景的Pytorch命名实体识别项目核心采用预训练模型加双向长短期记忆网络加条件随机场的结构同时内置四种对比模型共五种可切换方便研究不同网络的识别效果。代码注释详细数据处理、模型定义、训练评估、日志记录等模块划分清晰并配有人民日报、微软亚洲研究院等经典中文数据集新手也能看懂并快速部署使用。压缩包共五十八个文件包括十六个Python源码、九个文本说明、五张网络结构图、四个说明文档等整体大小仅十三点七五兆目录结构经过整理便于按需查阅。目前已有三百一十六人学习浏览项目曾获导师高度认可可作为完整参考也容易改造成其他序列标注任务。1. 为什么命名实体识别项目还在用BERTBiLSTMCRF这个组合做命名实体识别NER的人大概都被问过既然BERT已经能编码上下文为什么还要在它后面挂BiLSTM和CRF直接加个Softmax分类不好吗答案是在标注数据量不大、实体边界要求严格的场景里这套组合是效果和实现成本之间最划算的方案。BiLSTM把BERT输出的每个位置再做一次双向序列建模CRF则借助标签转移矩阵把B-PER后面不能跟O这类约束显式写进解码过程而不是让每个位置各自独立做决定。这个Pytorch项目正好把三者组装成完整链路源码、数据集、文档说明都齐适合想入门序列标注、又要跑通完整NER流程的工程师。2. 环境搭建与数据预处理Pytorch安装、BIO标签转换与Tokenizer对齐这份准备工作是整个项目里最琐碎、也最容易让人卡住的部分。模型代码写好了训练却跑不起来十有八九是环境版本不配套F1上不去先别急着调参回头查标签是不是对错了位。按下面三步把环境、数据和编码管道定死后面的模型部分就只是纯粹的张量拼接。2.1 Pytorch安装与CUDA版本配套怎么选这个项目要加载BERT预训练权重GPU和CPU版的训练速度能差近一个数量级建议直接上GPU版。先确认显卡驱动支持的CUDA版本再决定Pytorch版本。常见做法是用Anaconda建一个独立环境避免和系统Python互相污染conda create -n ner python3.10.11 -y conda activate ner pip install torch torchvision --index-url https://download.pytorch.org/whl/cu121 pip install transformers torchcrf seqeval网上经常能看到python 3.10.11 pytorch 2.8.0 cuda 12.1组合包的说法这类组合能不能用验证方法只有一个去download.pytorch.org/whl/cu121目录下看有没有对应的cp310 wheel文件有就装没有就换cu124或cu126的索引。Pytorch的GPU版本配套关系以官方wheel索引为准搜索引擎里的版本号往往来自旧帖子。Pytorch下载太慢是国内环境里最常见的问题正确做法不是去找来路不明的安装包而是把pip的默认源切到清华或阿里云镜像同时保留--index-url指定的Pytorch官方CUDA索引两者互不影响。参数说明--index-url指定CUDA版本的wheel索引cu121表示CUDA 12.1的运行时transformers负责BERT权重和Tokenizertorchcrf提供CRF层seqeval留到评估阶段算实体级指标。如果你打算读源码里的手写CRF实现装一个torchcrf做对照验证也很值得两者的输出应当一致。2.2 数据集格式与BIO标签体系中文NER数据集的通行格式是按字切分一字一行、一列字一列标签句子之间空一行标签体系最常见的是BIO和BIESO两种。BIO只有B-开头、I-中间、O无关三种基础标记BIESO在实体内部再分出E结尾和S单字实体对边界更敏感CRF对后者通常拟合得更好。不管用哪种label2id的构造方式一样labels [O, B-PER, I-PER, B-ORG, I-ORG, B-LOC, I-LOC] label2id {l: i for i, l in enumerate(labels)} id2label {i: l for l, i in label2id.items()}数据长这样小 B-PER 明 I-PER 在 O 北 B-LOC 京 I-LOC 上 O 班 O读文件时按空行切句子把字和标签分别收进两个list。这里有个容易忽略的点训练和推理必须用同一个max_len做截断超长句子直接从尾部截断不要从中间随机抠一段。实体通常横跨句内多个连续位置随机截断会把实体拦腰斩断造出一批教坏CRF转移矩阵的样本。拿到项目后建议先读文档说明里的数据格式约定确认它用的是BIO还是BIESO再写解析函数两套体系的边界判定逻辑不一样。2.3 用BertTokenizer对齐标签的三个坑BERT用的是WordPiece分词一个词会被切成多个subword而标注数据是一词一个标签编码后标签长度和token长度天然对不上这是序列标注项目里最大的坑。利用tokenizer返回的word_ids()把每个token映射回原始词位置从而对齐标签def align_and_encode(sentence, tags, tokenizer, label2id, max_len128): encoding tokenizer( sentence, truncationTrue, paddingmax_length, max_lengthmax_len, is_split_into_wordsTrue, return_tensorspt, ) aligned_labels [] prev_word None for word_idx in encoding.word_ids()[0]: if word_idx is None: aligned_labels.append(-100) # 特殊token与padding后续忽略 elif word_idx ! prev_word: aligned_labels.append(label2id[tags[word_idx]]) else: tag tags[word_idx] if tag.startswith(B-): aligned_labels.append(label2id[I- tag[2:]]) # subword续接转I- else: aligned_labels.append(label2id[tag]) prev_word word_idx encoding[labels] aligned_labels return encoding逻辑说明word_ids()对[CLS]、[SEP]和padding位置返回None这些位置统一填-100后面既不参与CRF损失也不参与评估。word_idx第一次出现的位置沿用原标签再次出现说明这是一个词内部被切开的subword此时如果原标签是B-开头要转成对应的I-否则CRF会看到词的第二个subword以B-开头这种错位序列。三个坑分别是特殊token不忽略、subword标签不转换、padding参加损失计算。任何一个踩了训练loss照样下降验证集F1却始终上不去。提示中文按字切分时一个字就是一个wordsubword分裂几乎只发生在中英混排的英文片段上。如果业务数据全是纯中文is_split_into_wordsTrue可以省略只要语料里可能出现英文姓名、代码片段这段对齐逻辑就必须保留。3. 模型结构BERT编码特征、BiLSTM序列建模与CRF约束解码模型文件的代码看着多结构其实就三块BERT把token id转成语义向量BiLSTM在向量序列上做双向扫描CRF在发射分数上做带约束的解码。下面按张量流的顺序拆开讲。3.1 BERT输出怎么喂给BiLSTM张量形状与维度变化BERT输入是[batch, seq_len]的token id输出是[batch, seq_len, 768]base版。两个细节要注意一是只取last_hidden_state不取pooler_output因为NER要求逐位置分类二是attention_mask要一路传给CRF但BiLSTM本身不接收mask参数所以CRF的mask要单独构造不能偷懒复用。每个位置的768维向量先过一个Dropout再进BiLSTM。BiLSTM的输出是前后两个方向拼接的维度是hidden_size * 2最后接一个Linear层映射到num_labels得到的就是发射分数emissions。发射分数表示每个位置取每个标签的原始得分它不是预测结果——加了CRF之后预测是整条序列联合决定的。想深入了解BERT预训练细节的可以看李沐对BERT论文的精读视频MLM和NSP两个目标讲得比较透但看与不看都不影响本项目代码的使用。3.2 用Pytorch实现BertBiLSTMCRF模型类完整模型类只有几十行关键是把三个组件的前向逻辑串对import torch.nn as nn from transformers import BertModel from torchcrf import CRF class BertBiLSTMCrf(nn.Module): def __init__(self, bert_path, num_labels, lstm_hidden256, dropout0.1): 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.fc nn.Linear(lstm_hidden * 2, num_labels) # 双向维度翻倍 self.crf CRF(num_labels, batch_firstTrue) def forward(self, input_ids, attention_mask): bert_out self.bert(input_ids, attention_maskattention_mask)[0] lstm_out, _ self.bilstm(self.dropout(bert_out)) return self.fc(self.dropout(lstm_out)) def loss(self, input_ids, attention_mask, labels): emissions self.forward(input_ids, attention_mask) mask labels ! -100 # 排除特殊token与padding return self.crf(emissions, labels, maskmask) def predict(self, input_ids, attention_mask): emissions self.forward(input_ids, attention_mask) return self.crf.decode(emissions, maskattention_mask.bool())参数说明lstm_hidden256是每个方向的隐藏维度双向拼接后是512所以fc的输入维度是lstm_hidden * 2。num_layers1够用词向量的深层抽象已经由BERT完成BiLSTM再叠两层收益很小还容易在数据量不大的时候过拟合。batch_firstTrue让BiLSTM输入保持[batch, seq, hidden]的顺序和BERT输出对齐避免转置带来的隐性bug。CRF的mask用labels ! -100而不是attention_mask.bool()attention_mask对[CLS]、[SEP]是1但这两个位置的标签是-100直接传会把-100当成标签索引训练时直接IndexError崩溃。3.3 CRF的转移矩阵与负对数似然损失CRF做的事情可以这样概括除了解码器给出的发射分数它还维护一个num_labels x num_labels的转移矩阵transitions[i][j]表示标签i后面接标签j的得分。训练时模型自动学会B-PER后面接I-PER是合理的O后面直接接I-PER会被压到很低的分数。torchcrf的forward返回的就是当前标签序列的负对数似然它本身就是损失值直接拿去backward()即可不需要再取负号。这正是CRF和普通Softmax的本质差别Softmax假设每个位置的标签独立预测CRF把整条标签序列当作一个整体打分相邻标签的合法性直接参与损失计算。4. 训练与评估分组学习率、早停与实体级F14.1 关键超参数怎么设BERT和BiLSTM要分开给学习率BERT是预训练权重微调学习率要压得很低不然会出现灾难性遗忘BiLSTM、FC和CRF是随机初始化的层学习率可以放开一到两个数量级。这是这套结构里最值得花时间的参数配置也是项目文档说明里通常会写明的东西参数常见取值说明bert层学习率2e-5 ~ 5e-5超过5e-5在中文语料上很容易训崩bilstm/fc/crf学习率1e-3 ~ 3e-3随机初始化层太小收敛慢batch_size16 ~ 32显存不够优先降到8max_len128 ~ 256按训练集句子长度分布确定warmup_ratio0.1前10%的步数线性预热epochs3 ~ 5配合早停不必训满lstm_hidden256显存紧张降到128对应到代码就是分组参数放进同一个优化器from torch.optim import AdamW from transformers import get_linear_schedule_with_warmup optimizer 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}, ]) total_steps len(train_loader) * epochs scheduler get_linear_schedule_with_warmup( optimizer, num_warmup_stepsint(0.1 * total_steps), num_training_stepstotal_steps, )分组学习率的原理是不同层处在不同的优化地形上BERT的权重已经收敛在预训练任务的谷底附近步子大了容易跳出好的区域随机初始化的层还在陡峭的初始位置学习率太小则在前几百步基本不动。warmup_ratio0.1的含义是前10%步数从零线性升到目标学习率避免BERT在大学习率下突然收到梯度冲击这在微调场景里是标配。4.2 训练循环、梯度裁剪与早停训练循环本身不复杂有两个细节必须写对。一是梯度裁剪BiLSTM对梯度爆炸敏感CRF转移矩阵在训练早期也可能产生很大梯度clip_grad_norm_设为5.0是常见做法。二是早停的判断标准要用验证集的实体级F1而不是训练loss或token级准确率因为O标签在数据里占比通常超过80%token级准确率很容易虚高到95%以上但实体一个都没找对。best_f1, bad_epochs 0.0, 0 for epoch in range(epochs): model.train() for batch in train_loader: loss model.loss( batch[input_ids].to(device), batch[attention_mask].to(device), batch[labels].to(device), ) optimizer.zero_grad() loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 5.0) optimizer.step() scheduler.step() f1 evaluate(model, val_loader) if f1 best_f1: best_f1 f1 torch.save(model.state_dict(), best_model.pt) bad_epochs 0 else: bad_epochs 1 if bad_epochs 3: break因为预处理阶段已经用paddingmax_length把所有样本pad到了统一长度DataLoader不需要额外写collate函数batch里的input_ids、attention_mask、labels天然是齐的形状直接取出来用就行。早停的patience设为3在这种规模的模型上比较合适BERT微调在第三到第五个epoch之间通常到达峰值之后训练loss还在降验证F1已经开始回落。4.3 用seqeval算实体级F1而不是token级准确率评估代码用seqeval按实体边界配对计算P/R/F1和token级指标是两个量级这也是项目文档里指标口径最容易被误读的地方。训练循环里调用的evaluate函数这样写from seqeval.metrics import classification_report, f1_score def evaluate(model, loader): model.eval() preds_all, trues_all [], [] with torch.no_grad(): for batch in loader: pred_ids model.predict( batch[input_ids].to(device), batch[attention_mask].to(device), ) for pred_seq, label_seq in zip(pred_ids, batch[labels]): pred_tags, true_tags [], [] for p, t in zip(pred_seq, label_seq): if t -100: continue # 过滤特殊token与padding pred_tags.append(id2label[p]) true_tags.append(id2label[t]) preds_all.append(pred_tags) trues_all.append(true_tags) print(classification_report(trues_all, preds_all)) return f1_score(trues_all, preds_all)逻辑说明classification_report会按实体类型分别输出P/R/F1中文NER重点关注PER、ORG、LOC三类某个类别分数明显低于其他类别时排查方向通常是标注一致性而不是模型容量。pred_ids来自模型的predict方法走的是CRF维特比解码不是逐位置argmax这一点在推理阶段不能改。5. 推理解码与两个容易翻车的细节5.1 推理必须走维特比解码不能逐位置取最大值推理阶段最常见的偷懒写法是把发射分数直接argmax(-1)出标签这在纯Softmax模型里说得通在CRF模型里等于把转移约束丢弃了。逐位置argmax完全可能输出O后面直接跟I-PER这类非法序列因为每个位置只看自己分数最高的标签。正确做法是调用模型的predict()内部走crf.decode的维特比算法在整条序列上找全局最优路径。这个改动对最终F1的影响通常在2到3个点属于不可省略的处理。5.2 长文本截断与CRF转移矩阵的读法推理时max_len128的截断会让长文档后半段的实体全部丢失。常见做法是滑动窗口按128个字切窗口相邻窗口重叠16个字对重叠区域的预测用投票合并。实体识别服务化时这是最常用的方案比硬调大max_len省显存得多。另一个很少人做的排错动作是把训练好的CRF转移矩阵打印出来看看trans model.crf.transitions.detach().cpu().numpy() for i in range(len(id2label)): top trans[i].argsort()[-3:] print(id2label[i], -, [id2label[j] for j in top if j ! i])转移矩阵能直接暴露模型学到的规律如果B-PER后面高频接I-PER说明内部标签一致性没问题如果O后面频繁接I-ORG典型原因是标注数据里漏标了实体开头。这类系统性错误反馈给标注侧修正比反复调学习率有效得多。模型上线后按实体类型和长度分布做bad case统计实体边界差一个字的错位大多指向标注规范不一致这类问题改数据比改模型更快见效。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价