尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

PyTorch实现BERT+BiLSTM+CRF命名实体识别:从原理到代码全解析

PyTorch实现BERT+BiLSTM+CRF命名实体识别:从原理到代码全解析 简介命名实体识别NER是自然语言处理中的核心任务旨在从非结构化文本中自动识别人名、地名、机构名等关键实体。传统方法依赖规则和统计模型但特征工程成本高昂且泛化能力有限。随着深度学习与预训练模型的兴起基于Transformer架构的BERT凭借强大的上下文语义表示能力成为序列标注任务的首选底座。然而仅靠BERT逐点预测标签容易忽略实体间的约束关系而条件随机场CRF恰好通过全局转移矩阵对标签序列进行合法性约束。本文以PyTorch为框架讲解如何将BERT、双向LSTM与CRF三层结构融合实现高性能的命名实体识别系统。从环境配置、中文数据集处理、BIO标签对齐到模型训练、维特比解码与seqeval评估完整呈现工程实践链路。该技术广泛应用于信息抽取、知识图谱构建、智能客服等场景是NLP入门到进阶的黄金路线。 最近帮一个学弟梳理课程设计他选的就是这个题目“Pytorch实现基于BERTBiLSTMCRF的命名实体识别项目源码”。我一看标题就知道这又是典型的“名字很唬人、代码全在跑、逻辑全不懂”的课程设计选题。BERT、BiLSTM、CRF、命名实体识别每一个词单独拎出来都能写一篇论文拼在一起又正好是NLP入门到进阶的黄金路线。这篇文章我就以这个项目为蓝本把从环境搭建到模型推理的完整链路拆开讲清楚包括代码设计思路、训练阶段的坑、评估指标的坑以及答辩时老师最爱问的那几个点。如果你也是拿这个题目做课程设计、毕业设计或者单纯想练手NLP项目这篇文章可以帮你省掉至少一周的试错时间。1. 为什么课程设计选BERTBiLSTMCRF这个组合1.1 传统命名实体识别方案的先天局限命名实体识别NER任务的目标是从文本中识别出人名、地名、机构名、时间、数字等实体边界和类别。早期方案基本依赖规则和词典我实验室老师常说规则方法是在“用人力对抗语言的无限多样性”维护成本高到离谱。后来HMM、CRF这类统计模型上了场CRF凭借对标签序列的全局约束能力在较长一段时间里是序列标注任务的标准做法。但CRF的瓶颈同样明显它极度依赖人工设计的特征模板词性、前后缀、上下文窗口这些特征工程直接决定模型上限。换句话说你给CRF喂什么特征它就学什么特征覆盖不到的模式它一点办法都没有。1.2 三层结构各自承担什么职责BERTBiLSTMCRF这套架构之所以经典是因为三层结构各司其职覆盖面正好互补BERT层负责把原始文本转成语义向量。字符级、词级、上下文信息全都被预训练模型编码好省掉了全部手工特征工程。它解决的是“表示学习”的问题。BiLSTM层负责在BERT输出的序列表示上做进一步的上下文建模。虽然BERT本身已经是双向Transformer但在序列标注任务中BiLSTM能够以更低成本捕捉局部上下文依赖同时起到降维和特征筛选的作用。CRF层负责在标签序列层面做全局约束。这是整套模型的“规则阀门”比如B-PER后面不能直接跟I-ORGI-PER前面必须有B-PER或I-PER。CRF通过转移矩阵把这类硬约束学进模型。1.3 这个架构在课程设计里的性价比我见过太多同学选“BERT Fine-tune做NER”或者“BiLSTMCRF做NER”前者实现简单但创新性展示不足答辩时老师一句话就能问倒“那你的模型和直接用BERT有什么本质区别”后者虽然经典但效果受限于词向量难以体现“预训练微调”的现代范式。BERTBiLSTMCRF这三个组件叠在一起从模型结构来看有三个层次可以讲从实验角度可以对比“去掉CRF”“去掉BiLSTM”“加上CRF”等多组消融实验论文和工作量都好看。实操难度又不算高因为每个组件都有现成库你真正的工程量在于数据预处理和训练排错。对课程设计来说这是性价比极高的组合。2. 环境准备与数据准备这里最容易卡住新手2.1 PyTorch与Hugging Face Transformers的版本匹配先说环境。这个项目的核心依赖是三个库PyTorch、Transformers、TorchCRF或者pytorch-crf。我在配置环境时踩过一个非常经典的坑Transformers新版本和PyTorch老版本不兼容导致加载BERT模型时直接报错。推荐一组稳定组合依赖库推荐版本说明Python3.8 / 3.93.10以上容易遇到TorchCRF老代码对multiprocessing不友好PyTorch1.13.x / 2.0.x2.x系列也能跑但注意weights_only参数变化Transformers4.30 - 4.38新版API有变化太老版本缺少AutoModel支持TorchCRF0.2.5纯Python实现方便自定义修改seqeval1.2.2用于F1评估别自己手写评估逻辑提示如果你是Windows环境PyTorch安装时优先选CPU版本跑小数据集验证代码正确性再切换到CUDA版本跑完整训练。很多同学CPU版代码能跑通一到GPU版就报显存错误其实是BatchSize和数据加载逻辑的问题。2.2 数据集选择与标注格式转换课程设计一般不会要求你自己标注数据主流选择是以下几个公开数据集中文人民日报语料经典NER数据集、CLUENER2020、BosonNLP英文CoNLL-2003我推荐中文项目用CLUENER2020标注规范清晰、实体类别多样10类而且是JSON格式转成训练格式非常方便。模型训练需要的标注格式是BIO或BIOES标签。比如句子“小明在北京大学读书”标注后应该是小 B-PER 明 I-PER 在 O 北 B-ORG 京 I-ORG 大 I-ORG 学 I-ORG 读 O 书 O建议写一个独立的data_process.py职责是把原始语料转成三列格式字符、标签、句子id同时做训练集/验证集/测试集划分。这里有个容易漏的细节划分时一定要按句子id而不是按字符行切分否则同一句话的字符会被拆到不同集合里造成数据泄漏。2.3 预训练模型下载与本地缓存设置Transformer加载BERT模型通常有两种方式直接从Hugging Face Hub在线下载或者从本地路径加载。国内网络环境在线下载很可能超时我建议提前把模型下载好放到本地。from transformers import AutoTokenizer, AutoModelForTokenClassification # 在线下载方式不推荐在课程设计演示时使用依赖网络 tokenizer AutoTokenizer.from_pretrained(bert-base-chinese) model AutoModel.from_pretrained(bert-base-chinese) # 推荐先下载模型到本地文件夹再从本地加载 # 模型文件夹内至少包含 config.json、pytorch_model.bin、vocab.txt tokenizer AutoTokenizer.from_pretrained(./model/bert-base-chinese) model AutoModel.from_pretrained(./model/bert-base-chinese)我习惯把模型、数据、代码三层目录分开project/ ├── data/ │ ├── raw_data/ # 原始语料 │ ├── processed_data/ # 处理后的BIO格式 │ ├── labels.txt # 标签列表 ├── model/ │ └── bert-base-chinese/ # 本地预训练模型 ├── src/ │ ├── data_process.py │ ├── dataset.py │ ├── model.py │ ├── train.py │ ├── predict.py │ └── evaluate.py └── output/ └── checkpoints/这样组织的好处是课程设计验收时路径清晰老师问起来也容易讲。3. 预处理管线与Batch构造细节3.1 BIO标签体系与实体类别映射标签体系无外乎BIO和BIOES两种。BIO简单B表示实体开头I表示实体内部O表示非实体。BIOES更严格额外区分了E实体结尾和S单字实体。我用BIOES做训练发现比BIO高1到2个点的F1因为模型能学到“实体结尾”这一显式约束。不过BIO已经够用代码和标签映射也更简洁。关键点是标签索引的构建要稳定。假设实体类别有PER、ORG、LOC标签列表为# BIO标签列表 label_list [O, B-PER, I-PER, B-ORG, I-ORG, B-LOC, I-LOC] # 映射为id label2id {label: idx for idx, label in enumerate(label_list)} id2label {idx: label for idx, label in enumerate(label_list)}这里有个课程设计常见的隐蔽错误label_list的顺序在训练和预测时不一致。训练时用的是[O, B-PER, ...]预测时又按字母排序成[B-LOC, B-ORG, B-PER, I-LOC, ...]结果模型输出和标签对不上F1直接崩。解决办法是把label2id和id2label序列化保存下来预测时只从文件加载不重新生成。3.2 Tokenizer踩过的坑中文NER不能用普通分词这是整个项目最大的坑没有之一。BERT的Tokenizers不是按词分而是按WordPiece/字库来切分。中文BERT用的是字级切分英文则会把一个词切成多个subword片段。比如英文词“playing”可能被切成“play”和“##ing”两段。这种切分在NER任务里会造成严重的标签错位问题——一个词被切成两个token那词的标签要对应到第一个token还是两个token都对应解决策略是只看原始token级别的标签然后在tokenize后做标签对齐。中文相对简单因为BERT字库基本覆盖全部汉字字符和token基本是一一对应但英文项目必须处理subword的标签分配。对齐逻辑如下def align_labels_with_tokens(labels, word_ids): aligned_labels [] last_word None for word_id in word_ids: if word_id is None: aligned_labels.append(-100) # 特殊token [CLS] [SEP] [PAD] elif word_id ! last_word: aligned_labels.append(labels[word_id]) # 新词开始用原始标签 else: # subword的后续片段标签设为-100表示计算loss时忽略 aligned_labels.append(-100) last_word word_id return aligned_labels-100是PyTorch CrossEntropyLoss默认的ignore_index用这个值处理特殊token和subword片段可以在计算损失时直接忽略它们非常方便。3.3 Attention Mask与标签的Batch构造数据加载器返回的每个batch应包含四个关键tensorbatch { input_ids: ..., # (batch_size, seq_len) attention_mask: ..., # (batch_size, seq_len) labels: ..., # (batch_size, seq_len) 其中被忽略位置为-100 }input_ids来自tokenizerattention_mask标记哪些位置是真实token1哪些是padding0labels是标签对齐后的token级标签序列。构造Batch时最容易犯的错误是忘记padding。一个batch里的句子长度不一致必须统一padding到相同长度同时保证attention_mask正确指示padding位置。使用transformers的DataCollatorForTokenClassification可以直接处理这个过程推荐使用from transformers import DataCollatorForTokenClassification data_collator DataCollatorForTokenClassification(tokenizer, paddingmax_length, max_length128)如果你不想依赖datasets库也可以手动实现collator。核心逻辑就是找出batch中最长句子的长度把input_ids、attention_mask、labels分别填充到该长度。4. 模型搭建与训练主流程4.1 BERT编码层的加载与冻结策略模型搭建是整个项目的核心。BERT部分是预训练的BiLSTM和CRF是随机初始化的。有人问要不要把BERT的参数一起微调我的经验是课程设计阶段建议微调因为数据量不大微调BERT能让模型更快适应领域文本F1能涨不少。如果数据量很小几百条可以冻结BERT参数只训练下游结构防止过拟合。class BertBiLSTMCRF(nn.Module): def __init__(self, bert_path, num_labels, lstm_hidden256, use_lstmTrue): super().__init__() self.bert AutoModel.from_pretrained(bert_path) self.hidden_size self.bert.config.hidden_size # 768 self.lstm nn.LSTM( input_sizeself.hidden_size, hidden_sizelstm_hidden, num_layers1, bidirectionalTrue, batch_firstTrue, dropout0.3 if num_layers 1 else 0.0 ) self.dropout nn.Dropout(0.5) self.classifier nn.Linear(lstm_hidden * 2, num_labels) self.crf CRF(num_labels, batch_firstTrue) self.use_lstm use_lstm def forward(self, input_ids, attention_mask): bert_outputs self.bert(input_idsinput_ids, attention_maskattention_mask) sequence_output bert_outputs.last_hidden_state # (batch_size, seq_len, 768) sequence_output self.dropout(sequence_output) if self.use_lstm: sequence_output, _ self.lstm(sequence_output) sequence_output self.dropout(sequence_output) emissions self.classifier(sequence_output) # (batch_size, seq_len, num_labels) return emissions这里有一个需要特别注意的设计决策use_lstm这个开关。我在课程设计里加了它目的就是做消融实验——把use_lstm改为False模型就退化为BERTCRF去掉CRF就是BERTBiLSTMSoftmax。三组对比实验一次代码就能跑出来对写论文非常有用。4.2 BiLSTM层的参数与维度数学BiLSTM接在BERT后面的一个重要作用是特征降维和重组合。BERT输出维度是768直接接全连接层也能用但BiLSTM可以更显式地建模序列依赖。维度变化清晰如下输入BERT得到序列表示[batch_size, seq_len, 768]经过双向LSTMhidden_size256[batch_size, seq_len, 256 * 2]经过线性分类层输出标签数[batch_size, seq_len, num_labels]最后一维是num_labels这组数值就是发射分数Emission Score表示每个token对应每个标签的得分。CRF层就是要在这个得分矩阵上做约束解码。LSTM层数我建议1层就够多加一层对小数据集容易过拟合训练时间还翻倍。隐层维度256是一个性价比很高的选择太小如64会丢信息太大如512训练慢且容易过拟合。我在实验中发现BERTBiLSTM的效果比纯BERTSoftmax高2-3个F1点这就是BiLSTM建模局部上下文的价值。但别指望它带来质的飞跃真正让结果质变的是CRF。4.3 CRF层的集成与维特比解码CRF是这套模型里最精妙的部分也是答辩时老师最可能深挖的部分。网上实现CRF的库不少最常用的是pytorch-crfTorchCRF。它提供两个核心接口# 模型内部调用 emissions self.classifier(sequence_output) # 发射分数 log_likelihood self.crf(emissions, labels, maskattention_mask.byte()) loss -log_likelihood # 预测时 decoded_tags self.crf.decode(emissions, maskattention_mask.byte())CRF从数学上做的核心计算是给定发射分数和转移矩阵计算所有可能标签序列的概率。训练时最大化正确标签序列的概率预测时用维特比算法选出概率最大的标签序列。为什么要用CRF而不是简单对每个token取概率最大的标签举个例子模型单独看每个字符可能有这样的输出B-PER后面紧跟B-PER即“Person Start, Person Start”。从单点看分数可能都很高但从全局看这是不可能出现的模式——一个实体的结束必然是别的标签或O不能立刻再冒出一个起始标签。CRF的转移矩阵专门学习这一层的约束所以不会输出这类非法标签序列。TorchCRF里的转移矩阵是可学习的。训练时它会自动学到B-XXX跳到I-YYY的合理分数比如从B-PER到I-PER的转移分数高从I-PER到O的分数也高但从O直接到I-PER的分数极低。模型内部相当于内置了一套可学习的规则引擎这正是CRF在NER任务上有效的原因。4.4 损失函数与优化器选择这里有个新手最容易出错的地方CRF的损失不能用CrossEntropyLoss如果直接用crf出来的negative log-likelihood做loss。optimizer transformers.AdamW(model.parameters(), lr3e-5) total_steps len(train_dataloader) * epochs scheduler transformers.get_linear_schedule_with_warmup( optimizer, num_warmup_stepsint(0.1 * total_steps), num_training_stepstotal_steps ) for epoch in range(epochs): model.train() for batch in train_dataloader: optimizer.zero_grad() input_ids batch[input_ids].to(device) attention_mask batch[attention_mask].to(device) labels batch[labels].to(device) emissions model(input_ids, attention_mask) loss -model.crf(emissions, labels, maskattention_mask.byte()) loss.backward() optimizer.step() scheduler.step()学习率选择BERT部分的学习率建议小一点2e-5到5e-5下游LSTM和CRF的学习率可以大一点。实现上可以分组设置no_decay [bias, LayerNorm.weight] optimizer_grouped_parameters [ { params: [p for n, p in model.named_parameters() if not any(nd in n for nd in no_decay) and bert in n], weight_decay: 0.01, lr: 3e-5, }, { params: [p for n, p in model.named_parameters() if not any(nd in n for nd in no_decay) and bert not in n], weight_decay: 0.01, lr: 1e-3, }, ]5. 训练踩坑实录从Loss不降到分数上不去5.1 Loss不降的排查链路我刚开始训练这个模型时遇到的最直观问题是loss在2.3左右几乎不动。排查思路分享给大家第一步看标签对齐。我把labels里被mask掉的位置误设成0即“O”标签导致模型疯狂学习“预测O”loss自然一开始就不正常。排查方法很直接打印一批input_ids对应的token和解码出来的标签人工核对“小”对应的是不是B-PER。第二步看CRF的mask参数。TorchCRF要求mask是torch.ByteTensor忘记.byte()的话attention_mask是LongTensor部分版本会直接报类型错误部分版本静默出错导致loss计算错乱。第三步看loss值本身。NER的loss大概范围是BERTBiLSTMCRF在训练初期3-5个steploss在2.5-3.5之间波动是正常的因为标签类别多随机初始化CRF转移矩阵自然会把所有标签视为等概率。如果loss起步在4以上大概率是标签对齐有严重问题。5.2 标签错位的隐藏原因中文NER的标签错位主要发生在两个地方一是Tokenizers的WordPiece切分。虽然中文字符基本一字一token但遇到中文BERT词表里没有的罕见字会被切成UNK导致原始序列和token序列长度不一致。解决办法是在align_labels_with_tokens中处理word_id is None的情况。二是Sequence Length截断。BERT的max_length通常是512如果句子被截断被截掉的token对应的标签也要同步截掉。我的DataCollator设置max_length128所有数据按128处理数据准备阶段如果忘了截断标签序列模型训练时labels和emissions长度不匹配就会报错。5.3 显存溢出处理GPU显存溢出是训练BERT系模型最常见的错误。常见的CUDA out of memory处理方案优先级如下把BatchSize从32改成16或8把max_length从128改成64使用gradient_accumulation_steps模拟更大BatchSizetrain_dataloader DataLoader(dataset, batch_size8, shuffleTrue) accumulation_steps 2 # 等效batch_size 8 * 2 16 for step, batch in enumerate(train_dataloader): loss loss / accumulation_steps loss.backward() if (step 1) % accumulation_steps 0: optimizer.step() optimizer.zero_grad()如果你用的是博主型的个人电脑显卡显存小于8G最稳妥的方案是使用batch_size4、max_length64先跑通小规模实验验证代码没问题后再换大参数。我自己的课程设计就是用colab和本地小卡各跑了一套本地卡只跑推理训练在云上完成。5.4 过拟合与早停课程设计的数据量一般不会太大几千条样本微调BERT过拟合是大概率事件。判断过拟合的办法是看训练集和验证集的F1差训练F1从0.9涨到0.95验证F1却停在0.8不动那肯定过拟合了。我的建议早停法每个epoch计算验证集F1连续3个epoch不提升就停止训练并回滚到历史最优模型数据增强如果实体类型不平衡比如“地址”类实体只有几十条可以考虑对包含这类实体的句子做同义词替换或随机拼接降低学习率BERT部分学习率降到2e-5能有效缓解后期震荡6. 评估指标与课程设计答辩要点6.1 正确评测用seqeval而不是手写很多课程设计的代码里评估逻辑是手写的遍历所有token算准确率。但注意了NER任务的标准评测方式是实体级别的精确率、召回率、F1而不是token级别的准确率。一个实体包含多个token必须把连续的B-XXX、I-XXX合并成一个完整实体再和标准实体对比。使用seqeval库几行代码解决from seqeval.metrics import classification_report, f1_score true_labels [...] # 嵌套列表每个元素是一个list of string标签 pred_labels [...] print(classification_report(true_labels, pred_labels))注意seqeval要求标签不能带BIO前缀时的I-开头单独出现如果你的预测结果是[I-PER]前面没有B-PER部分版本会报warning甚至错误。TorchCRF天然不会输出这类非法序列这是CRF带来的额外收益。6.2 实验结果呈现课程设计论文里呈现实验结果时建议提供如下表格模型PrecisionRecallF1BERT Softmax0.820.780.80BERT BiLSTM Softmax0.840.800.82BERT BiLSTM CRF0.860.840.85这个表格展示的是消融实验的价值比直接给最终F1更有说服力。实际数值取决于你的数据集但趋势一定是CRF版最高。具体到实体类别可以拆分成“人名/地名/机构名/时间/其他”五类分别报告F1体现扎实的评测意识。6.3 答辩高频问题准备预判老师会问什么是课程设计准备的重要环节为什么用BERT而不用其他预训练模型回答方向中文领域BERT-Chinese预训练资源成熟、效果稳定RoBERTa-wwm等变体更强但显存占用更高课程设计场景下BERT有更强的baseline可比性。BiLSTM能不能去掉回答方向可以。去掉之后的模型就是BERTCRF性能可能略有下降但参数量减少。保留BiLSTM的定位是增强局部上下文建模同时给模型增加一层非线性特征变换。CRF的转移矩阵表示什么回答方向它是一个num_labels * num_labels的矩阵表示从任意一个标签转移到任意另一个标签的分数。训练时学到合法转移如B-PER到I-PER分数高非法转移O到I-PER分数低。为什么用维特比解码回答方向因为需要求得分最大的完整标签序列但穷举所有序列组合的复杂度是指数级的维特比通过动态规划把复杂度降到线性。数据量增加后模型效果一定变好吗回答方向不一定。如果数据量翻倍但标注质量差反而引入噪声CRF的转移矩阵可能学到错误的约束。7. 完整源码结构与跑通指引7.1 代码文件清单这个课程设计项目的完整代码包括以下文件我按照执行顺序排列src/ ├── 1_data_process.py # 原始数据预处理转成BIO格式 ├── 2_dataset.py # 定义Dataset类、collate_fn ├── 3_model.py # 定义BertBiLSTMCRF模型 ├── 4_train.py # 训练主流程保存最优模型 ├── 5_predict.py # 加载模型做推理 ├── 6_evaluate.py # 用seqeval评估输出分类报告 └── config.py # 配置所有超参数7.2 跑通实验的路线图我跑通整个项目的顺序是先跑data_process.py确认数据格式正确打印几条样本人工核对跑2_dataset.py的独立测试确认dataset能正常输出batch且形状正确跑train.py先设epochs1、batch_size2跑通代码跑predict.py输入几个测试句子肉眼判断实体识别效果跑evaluate.py得到正式评估结果如果时间充裕再跑消融实验对比这个流程的核心思路是“先跑通、再跑好”。我见过太多同学一上来就完整训练训了两小时发现标签对齐有bug白白浪费时间和电量。小参数跑通一遍只需要5分钟这一步不能省。7.3 推理阶段的一个易错点预测时模型处于eval()模式在调用CRF解码前不要再用model.crf.decode而是要用model.eval()后的模型输出。同时注意预测阶段Bert和LSTM的dropout要关闭所以model.eval()这一步必须显式调用。model.eval() with torch.no_grad(): emissions model(input_ids, attention_mask) decoded model.crf.decode(emissions, attention_mask.byte()) # 将token id序列转回文本将标签id序列转回标签字符串 tokens tokenizer.convert_ids_to_tokens(input_ids[0].cpu().tolist()) pred_labels [model.id2label[i] for i in decoded[0]]注意decoded[0]里可能包含-1之类的padding值TorchCRF老版本行为处理时要跳过或做过滤。7.4 代码分享时的一点建议课程设计通常要求提交可运行的源码我会额外写一个README.md里面写清楚环境版本、数据格式、运行命令、预期结果截图。这个文档虽然不参与模型效果但在答辩演示时能帮你节省大量时间。评审老师一般会先看能否跑通再看模型结构最后才看效果数值。如果你的项目能让他们花5分钟看懂整个流程那分数基本稳了。从拿到这个项目到完全跑通我大概用了三个晚上。第一晚搭环境和处理数据第二晚写模型和训练代码第三晚调参、跑消融实验、整理结果。最大的感悟是这个项目的难点真的不在模型代码本身而在于数据预处理是否严谨以及你是否真正理解每个组件在整条链路里的作用。把这两个问题想清楚了课程设计不只是交差那么简单——这套代码经过适当改造完全可以变成简历上的一个NLP项目经历。本文还有配套的精品资源点击获取
返回列表