ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

基于PyTorch的BERT+BiLSTM-CRF中文命名实体识别实现与工程实践

基于PyTorch的BERT+BiLSTM-CRF中文命名实体识别实现与工程实践 简介这是一份面向自然语言处理初学者与进阶研究者的中文命名实体识别NER实战代码资源基于PyTorch框架实现BERT-BiLSTM-CRF端到端模型解决中文文本中人名、地名、机构名等实体的精准识别问题适用于学术实验、课程设计及工业级NER任务快速验证。压缩包共15个文件包含9个核心Python模块如models.py实现BERT嵌入BiLSTM特征提取CRF解码processor.py支持中文分词与标签对齐main.py与predict.py分别封装训练与推理流程、4个文本配置/说明文件、1个README.md和1个.gitignore整体仅373KB轻量易部署。已有152人学习下载资源结构清晰、模块职责分明提供完整训练—验证—预测闭环含conlleval.py标准评估脚本、logger.py统一日志管理、config.py参数集中配置以及requirements.txt依赖清单开箱即用显著降低BERT微调与序列标注模型复现门槛。 中文命名实体识别这个任务做过的都知道最折磨人的往往不是模型选型而是“看起来该对的地方全错了”。如果你在GitHub上搜中文NER的代码大概率会撞见BERTBiLSTM-CRF这个组合。我第一次看到这个方案时也疑惑过既然BERT预训练模型已经那么强了为什么还要在它后面再接BiLSTM和CRF直到自己动手用PyTorch把整条链路跑通才真正理解这个组合为什么能成为中文命名实体识别的主力基线——它不是简单的堆叠而是把“上下文语义理解”和“标签之间的强约束”分工明确地交给了不同的层。这篇文章会从选型逻辑、环境搭建、数据预处理、模型实现、训练调参到推理解码完整拆解我在PyTorch下实现基于BERT预训练的BiLSTM-CRF中文命名实体识别模型的全部过程。适合两类读者一类是刚接触NER、想直接抄一套能跑通代码的人另一类是已经用softmax做过序列标注、想搞清楚CRF到底解决了什么问题的人。1. 为什么是BERTBiLSTM-CRF中文NER的选型复盘1.1 中文命名实体识别的真实难点中文命名实体识别和英文有个很明显的区别中文没有天然的空格分词边界。分词工具的误差会直接传导到实体边界上比如“南京市长江大桥”这种经典歧义句分词结果不同实体就完全不同。所以中文NER社区后来普遍转向字符级标注让模型自己学出“哪些字组合在一起构成实体”而不是依赖外部分词器。抛开边界问题真正的难点可以拆成三个。第一是边界识别实体从哪里开始、到哪里结束比如“张三”是一个完整人名漏掉“三”或者多算一个字都是错。第二是类型判定“苹果”在“苹果很好吃”里是水果在“苹果公司发布新品”里是机构同一个词在不同语境下的实体类型不一样。第三是标签依赖在BIO标注体系下I-PER必须跟在B-PER后面一个实体内部不能跳到别的类型两个同类型实体也不能紧挨着用一个B标签糊弄过去。这三个难点直接决定了技术选型。前两个靠语义表示解决第三个需要在序列解码层面加约束。所以一个合理的中文NER方案必然是“语义编码器序列解码器”的组合而不是简单地做逐字分类。1.2 从BiLSTM-CRF到BERTBiLSTM-CRF的进化逻辑在BERT还没普及的年代BiLSTM-CRF是序列标注的标配方案。BiLSTM把双向上下文编码进隐藏状态CRF在标签序列层面建模转移约束这个组合已经相当能打。但它的上限受限于输入端的词向量。word2vec这类静态词向量是“一词一向量”同一个“苹果”在不同语境下的表示完全相同模型只能靠大量标注数据去隐式地学消歧标注样本一旦少效果就很不稳定。BERT预训练模型改变了这个局面。它通过大规模语料预训练让每个token的表示都融入了上下文信息能做到“一词多义”的动态表征。BERTBiLSTM-CRF的思路就是把两代技术拼在一起用预训练好的BERT输出动态语义向量BiLSTM在BERT的基础上再做一次序列特征提炼CRF保证最终输出的标签路径全局最优。三种常见方案的对比如下方案语义表示能力标签约束训练成本典型问题纯BiLSTM-CRF弱多义词难区分强低依赖词向量质量BERTSoftmax强弱中输出可能有非法标签序列BERTBiLSTM-CRF强强高训练和调参更复杂我在实际项目里对比过同样是标注了2万条中文句子纯BiLSTM-CRF的实体级F1大概在80左右BERTSoftmax能到88而BERTBiLSTM-CRF可以稳定到91以上。收益最明显的是在“实体边界”这一项上CRF的全局解码确实把相邻标签的约束吃透了。2. 环境准备PyTorch与预训练模型版本匹配的实操细节2.1 PyTorch安装的版本选择逻辑我用的环境是Python 3.10 PyTorch 2.1 CUDA 12.1的组合。这里重点说下为什么不能随便挑版本。PyTorch的API在2.x以后变化比较快比如2.6开始torch.load的weights_only默认值变成了True直接导致很多老脚本加载权重时报错。如果你拿网上的老代码跑最稳的方案是安装和自己代码匹配的PyTorch版本而不是一上来就装最新版。安装命令本身并不复杂。用conda创建虚拟环境再用pip安装指定CUDA版本的PyTorchconda create -n ner python3.10 conda activate ner pip install torch2.1.2 torchvision0.16.2 --index-url https://download.pytorch.org/whl/cu121GPU版本怎么选先跑nvidia-smi看驱动支持的CUDA版本然后装不高于这个版本号的PyTorch。比如驱动支持CUDA 12.2那装cu121没问题如果驱动只支持CUDA 11.8就得装cu118对应的torch版本。如果是纯CPU机器直接pip install torch就行但训练BERT会很慢建议至少有一块6G显存以上的显卡。显存不够时batch size就压到8甚至4别硬上大batch。2.2 transformers和中文预训练模型的搭配transformers库版本也直接影响代码行为。我用的是4.36.x这个版本对BertModel.from_pretrained兼容性很好配合bert-base-chinese几乎没有问题。中文NER预训练模型常用的有两个bert-base-chinese和roberta-wwm-ext。后者的全词掩码训练方式对中文更友好但兼容性稍差有些版本需要你自己指定vocab.txt路径。我的建议是第一次跑通先用bert-base-chinese把代码逻辑验证没问题后再换roberta提升效果。频繁切换预训练模型时还要记得清空HuggingFace的缓存目录否则可能出现“明明换了模型路径实际加载的还是旧权重”的怪问题。如果下载慢最省事的办法是手动下载pytorch_model.bin、config.json、vocab.txt三个文件放到本地目录然后直接指定本地路径加载from transformers import BertModel, BertTokenizer model_dir /your/local/path/bert-base-chinese tokenizer BertTokenizer.from_pretrained(model_dir) model BertModel.from_pretrained(model_dir)这样绕开了在线下载流程也避免了一次次网络超时导致的反复失败。我踩过一次坑一开始直接在代码里写from_pretrained(bert-base-chinese)每次都在下载环节卡住后来改成本地加载整个流程才稳定下来。3. 数据管线BIO标注、字符级对齐与DataLoader实现3.1 数据格式与BIO标注规范学习NER先要接受一句话数据格式决定了模型上限。我用的数据是人民日报标注语料格式如下张 B-PER 三 I-PER 在 O 北 B-LOC 京 I-LOC 工 O 作 O空行表示一句话结束。读取时按句子切分得到chars列表和labels列表。另一个细节标签集合要固定训练前先统计所有标签并构造label2id和id2label后续推理也使用同一份映射。不要图省事直接写死标签顺序因为label2id一旦变了模型输出的id意义就全乱了。常见的标签体系有BIO和BIOES两种。BIO是Begin/Inside/OutsideBIOES在BIO基础上增加了EEnd和SSingle对边界表达得更细。CRF时代BIOES的效果通常比BIO稍好因为单字实体用S标签可以避免“B-PER然后必须接I-PER”的尴尬。但要注意如果换标签体系CRF的转移矩阵含义也要跟着变不能混用。3.2 tokenize与标签对齐的难点这是整个项目里最容易出bug的地方。BERT的tokenizer对中文基本按字切分但遇到生僻字、特殊符号时一个字符可能被拆成多个token甚至变成[UNK]。如果直接拿tokenizer的输出长度和标签序列对应一定会错位。解决办法是逐字符tokenize并扩展标签。核心代码如下def encode_with_labels(text, labels, tokenizer, max_len128): tokens [] label_ids [] for char, label in zip(text, labels): tokenized tokenizer.tokenize(char) if len(tokenized) 0: tokenized [[UNK]] tokens.extend(tokenized) label_ids.extend([label] * len(tokenized)) # 截断到 max_len - 2预留 [CLS] 和 [SEP] tokens tokens[:max_len - 2] label_ids label_ids[:max_len - 2] tokens [[CLS]] tokens [[SEP]] label_ids [-100] label_ids [-100] input_ids tokenizer.convert_tokens_to_ids(tokens) attention_mask [1] * len(input_ids) token_type_ids [0] * len(input_ids) return { input_ids: input_ids, attention_mask: attention_mask, token_type_ids: token_type_ids, labels: label_ids, }这里有个很实用的小技巧在[CLS]和[SEP]位置以及padding位置都用-100占位。之后用nn.CrossEntropyLoss(ignore_index-100)计算损失时这些位置会被自动跳过完全不用手动写mask逻辑很省事。3.3 DataLoader的padding策略与collate_fn自定义Dataset类很容易关键是collate_fn。每个样本的序列长度不一样必须在batch内做padding。BERT要求同一batch内的token长度一致所以padding策略直接影响显存占用和训练速度。def collate_fn(batch): input_ids [item[input_ids] for item in batch] attention_mask [item[attention_mask] for item in batch] token_type_ids [item[token_type_ids] for item in batch] labels [item[labels] for item in batch] max_len max(len(ids) for ids in input_ids) padded_input_ids [ids [0] * (max_len - len(ids)) for ids in input_ids] padded_attention_mask [mask [0] * (max_len - len(mask)) for mask in attention_mask] padded_token_type_ids [tt [0] * (max_len - len(tt)) for tt in token_type_ids] padded_labels [lab [-100] * (max_len - len(lab)) for lab in labels] return { input_ids: torch.tensor(padded_input_ids, dtypetorch.long), attention_mask: torch.tensor(padded_attention_mask, dtypetorch.long), token_type_ids: torch.tensor(padded_token_type_ids, dtypetorch.long), labels: torch.tensor(padded_labels, dtypetorch.long), }注意区分padding的填充值input_ids填0attention_mask填0但labels必须填-100。如果labels上填了0模型会把padding位置也当作标签0来学习直接污染训练。还有一个提速技巧先按句子长度排序再分batch同batch内长度接近padding比例小训练能快不少尤其是对长度差异很大的语料。4. 模型实现拆解BERT编码、BiLSTM特征提炼、CRF序列解码4.1 BERT编码层冻结、微调与分层学习率先澄清一个容易误解的点标题里说的“基于BERT模型在BiLSTM-CRF模型上进行预训练”这个说法容易让人以为要拿BERT去预训练下游模型。实际上这套代码做的是用预训练好的BERT权重作为上游编码器在其上接BiLSTM和CRF然后在自己标注数据上做端到端的微调。BERT的权重已经在大规模语料上预训练过了不需要再来一次预训练剩下的是微调。那BERT层到底微不微调我的建议分两种情况。如果只有几千条标注数据可以先把BERT冻结只训练BiLSTM和CRFfor param in model.bert.parameters(): param.requires_grad False这样做训练速度快也不容易在少量数据上过拟合但效果上限有限。如果数据在几万条以上就解冻BERT并采用分层学习率BERT参数用2e-5下游层用1e-4。原因是BERT的预训练权重已经学到通用语义用大学习率微调反而容易“灾难性遗忘”把学到的先验知识冲掉。4.2 BiLSTM层的参数设计与模型结构先看完整的模型类import torch import torch.nn as nn from transformers import BertModel class BertBiLSTMCRF(nn.Module): def __init__(self, bert_dir, num_tags, lstm_hidden128, dropout0.5): super().__init__() self.bert BertModel.from_pretrained(bert_dir) self.lstm nn.LSTM( input_size768, hidden_sizelstm_hidden, num_layers1, bidirectionalTrue, batch_firstTrue, ) self.dropout nn.Dropout(dropout) self.fc nn.Linear(lstm_hidden * 2, num_tags) self.crf CRF(num_tags) def forward(self, input_ids, attention_mask, token_type_ids, labelsNone): outputs self.bert( input_ids, attention_maskattention_mask, token_type_idstoken_type_ids, ) sequence_output outputs.last_hidden_state lstm_out, _ self.lstm(sequence_output) lstm_out self.dropout(lstm_out) emissions self.fc(lstm_out) if labels is not None: return self.crf.forward_loss(emissions, labels, attention_mask.bool()) return self.crf.decode(emissions, attention_mask.bool())几个参数需要解释。input_size768是BERT的hidden_sizehidden_size设为128因为双向LSTM输出维度是128 * 2 256传给全连接层后输出num_tags。num_layers1就够用层数再多训练变慢还容易过拟合batch_firstTrue是为了和BERT输出的[batch_size, seq_len, hidden_size]对齐。有一个经验值得分享BiLSTM在这里的作用不是“提高语义上限”而是把BERT的768维表示压缩到一个较低维度的序列特征空间让CRF学标签转移时更聚焦。你把BiLSTM的hidden_size减到64效果也不会有明显下降反而速度更快。4.3 CRF层转移矩阵与维特比解码CRF是整个模型的灵魂它解决的是softmax解决不了的问题标签之间的依赖关系。softmax对每个位置独立分类它不知道“B-PER后面不能跟B-ORG”这种约束所以预测结果里可能出现连续两个B标签或者凭空冒出来的I标签。CRF在标签序列层面建模转移概率输出一条全局最优的标签路径。CRF的核心是一个num_tags * num_tags的转移矩阵transitions[i][j]表示从标签i转移到标签j的得分。训练时CRF最大化正确路径的得分与所有可能路径得分之和的差值本质上是序列级的对数似然。解码时用维特比算法不能用贪心——贪心在每个位置取最大概率标签但局部最优的拼接不等于全局最优。简化版的CRF核心实现如下class CRF(nn.Module): def __init__(self, num_tags): super().__init__() self.num_tags num_tags 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_loss(self, emissions, tags, mask): # emissions: [batch, seq_len, num_tags] # tags: [batch, seq_len] # mask: [batch, seq_len], 记录有效token位置 batch_size, seq_len, _ emissions.shape score self.start_transitions[tags[:, 0]] for t in range(seq_len - 1): mask_t mask[:, t] mask[:, t 1] score score emissions[torch.arange(batch_size), t, tags[:, t]] score score self.transitions[tags[:, t], tags[:, t 1]] * mask_t.float() last_tags tags.gather(1, mask.sum(dim1, keepdimTrue) - 1).squeeze(1) score score emissions[torch.arange(batch_size), mask.sum(dim1) - 1, last_tags] score score self.end_transitions[last_tags] return -score.mean()实际项目我通常直接用TorchCRF这个库或者参考transformers里BertForTokenClassification的CRF实现自己手写容易在mask和边界处理上出错。手写一遍的价值在于理解原理生产环境直接用验证过的库更稳。5. 训练与调参学习率、梯度裁剪和早停的实测经验5.1 训练循环与梯度裁剪训练BERT类模型优化器首选AdamW不是Adam。AdamW把权重衰减和梯度更新解耦对BERT这类带LayerNorm的模型更友好。配合get_linear_schedule_with_warmup做学习率调度前10%的step线性warmup之后再线性衰减。每个batch都要做梯度裁剪optimizer AdamW(model.parameters(), lr2e-5) total_steps len(train_loader) * epochs scheduler get_linear_schedule_with_warmup( optimizer, num_warmup_stepsint(total_steps * 0.1), num_training_stepstotal_steps, ) for epoch in range(epochs): model.train() total_loss 0 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) optimizer.zero_grad() loss model(input_ids, attention_mask, token_type_ids, labels) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) optimizer.step() scheduler.step() total_loss loss.item()梯度裁剪要在loss.backward()之后、optimizer.step()之前执行。BERT类模型很容易在一个异常batch上梯度爆炸loss直接变成NaN。max_norm1.0是我试过比较稳的值太小会拖慢收敛太大会让loss波动明显。还有一个细节model.train()和model.eval()切换不能忘。BERT里的Dropout和LayerNorm在两种模式下行为不同如果推理时忘了切回eval模式预测结果每次都不一样验证分数也会虚高。5.2 学习率与超参数实测配置下面是我跑了多组实验后固定下来的一套配置超参数推荐值说明max_len128长句子会被截断语料偏长时调到256batch_size16或32显存不够就梯度累积BERT学习率2e-5解冻BERT时使用下游学习率1e-4BiLSTM和CRF层用warmup比例0.1前10%step线性warmupepochs3-5中文NER上3轮基本收敛dropout0.5BiLSTM输出后的dropoutmax_grad_norm1.0防梯度爆炸为什么epochs不能太多BERTBiLSTM-CRF参数量大中文NER标注数据通常也就几万条跑到第5轮以后验证F1基本不再上升训练loss还在降这就是典型的过拟合信号。如果你的数据量特别大比如10万条以上可以适当多跑几轮但普通场景3到5轮足够了。5.3 验证指标与早停策略NER任务一定要用F1不要用accuracy。原因很直接句子里的“O”标签占绝大多数accuracy随便就能上95%但这对实体识别没有任何参考价值。F1计算时有两种粒度token-level只看每个token预测对不对entity-level要求实体类型和边界完全匹配才算对。后者更严格也更接近业务真实诉求。我直接用seqeval库代码很简洁from seqeval.metrics import classification_report, f1_score pred_lists ... # 每个样本的预测标签列表形如 [[B-PER,I-PER], [O]] true_lists ... # 真实标签列表 print(classification_report(true_lists, pred_lists))早停策略每个epoch结束跑一次验证集验证F1连续两个epoch不提升就提前停止并保存F1最高的那个模型权重。保存时有个容易被忽略的点——不能只保存model.state_dict()要连同label2id、id2label和模型配置一起存否则推理时没法把分类id映射回原始标签。6. 推理解码与踩坑记录从标签序列还原实体6.1 从预测标签到实体列表的还原推理流程和训练有几处不同。模型切到eval模式代码用torch.no_grad()包住然后得到CRF解码出的标签序列。注意CRF返回的是每个token的标签id序列长度等于padding后的序列长度必须把[CLS]、[SEP]和padding位置都去掉再还原到原始字符。实体合并的代码如下def extract_entities(chars, tags, id2label): entities [] current_entity None for idx, (char, tag_id) in enumerate(zip(chars, tags)): tag id2label[tag_id] if tag.startswith(B-): if current_entity: entities.append(current_entity) current_entity { type: tag[2:], start: idx, text: char, } elif tag.startswith(I-) and current_entity and current_entity[type] tag[2:]: current_entity[text] char else: if current_entity: entities.append(current_entity) current_entity None if current_entity: entities.append(current_entity) return entities这里有一个容易踩的低效点推理时如果不把padding去掉直接把整条序列的标签结果拿回来标签长度和原始字符长度对不上合并出来的实体就是乱的。我见过不少人在这个环节反复调试其实就是对齐的逻辑没理清。6.2 三类高频错误与排查思路第一类所有预测都是O模型完全“罢工”了。原因通常是数据里O类别占比太高模型学到“全预测成O也能拿到很低的loss”或者BERT被冻结且下游学习率太小模型根本没学到实体特征。解法把下游学习率调到5e-5到1e-4或者解冻BERT最后两层再试。第二类标签整体错位比如“张三”预测成了“张O三PER”。十有八九是tokenize对齐函数写错了。排查方法很直接随机取一条训练样本打印token_ids、labels、attention_mask肉眼检查[CLS]和[SEP]位置是否都对应-100实体首字的标签是否落在对应的字上。我每次遇到诡异的结果都会先做这一步基本能定位90%的问题。第三类验证集F1很高一到真实文本上效果就崩。通常原因是训练语料和实际应用本文还有配套的精品资源点击获取
返回列表