尧图精选

PyTorch实现BERT-BiLSTM-CRF命名实体识别实战

🕒 发布时间:2026/9/20 13:01:46 📁 来源:尧图网络
简介基于PyTorch实现的BERT-BiLSTM-CRF命名实体识别项目面向NLP初学者与研究者提供从模型构建、训练到评估的完整可运行方案。项目整合预训练BERT、双向LSTM与CRF解码层在中文NER数据集上完成微调适用于信息抽取、知识图谱等场景。压缩包共6个文件包含5个Python脚本和1个Markdown说明文档分别覆盖模型定义、训练流程、数据处理与指标评估整体体积仅13KB便于快速移植与学习。目前已有261人学习。除源码外还附带经过清洗和格式化的NER数据集以及详细的项目说明清晰解读环境配置、数据预处理、参数调优和推理部署等关键步骤可帮助使用者从零构建高准确率命名实体识别系统。整体代码结构清晰、注释详尽兼顾性能与可复现性具备较强参考价值。1. 基于PyTorch实现BERT-BiLSTM-CRF做NER自己复现一遍才能改得动给合同审阅、病历结构化或工单分类做命名实体识别第一反应是直接调现成的API或开源模型。等真把线上数据拉回来推一遍会发现通用模型用自己的实体规范对不齐有人把“北京协和医院”切成“北京”和“协和医院”有类型错标成机构有边界多吞一个字。这种时候就得训练自己的NER模型。用PyTorch把BERT-BiLSTM-CRF串起来是当前工业界最稳妥的一套NER基线和最常被复现的架构方案BERT负责把上下文语义编码成向量BiLSTM在序列方向再压一遍局部信息CRF学标签之间的转移关系最终输出严格合法的标签序列。这个组合不保证在每份数据集上刷到最高分但边界稳定性和人工干预空间明显优于单块BERT直接接softmax。下面按环境、数据、模型、训练、落地五步展开新手能跟到跑通熟手可以直接跳到自己缺的那章。2. PyTorch环境搭建与NER数据集准备先对齐版本再对齐标签格式2.1 ANACONDA创建PyTorch虚拟环境CUDA 12.1组合版与本地验证命名实体识别常用的PyTorch基础框架版本环境并不复杂但新手最容易在第一步被卡住。常见做法是先创建独立虚拟环境避免把系统Python搞乱也方便后面在PyTorch和TensorFlow之间切换。以目前常见的组合为例Ubuntu和Windows都适用conda create -n ner python3.10 -y conda activate ner pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu121 python -c import torch; print(torch.__version__, torch.cuda.is_available())python 3.10.11搭配 PyTorch 2.8.0 CUDA 12.1 是当前通过 ANACONDA 配置 PyTorch 环境时比较稳的组合包。--index-url指向 cu121 表示安装的 torch 预编译版本对应 CUDA 12.1显卡驱动版本只要不低于 CUDA 12.1 对应的小版本就能正常识别。如果机器没有独立显卡把地址换成cpu版本即可代码逻辑不受影响。最后一行torch.cuda.is_available()输出True说明 GPU 可用输出False时先查驱动版本其次查 torch 版本是否匹配不要急着重装系统或换Python版本。项目下载后第一步往往是查看requirements.txt里面如果写了transformers、datasets、seqeval、pytorch-crf这些依赖建议全部放进虚拟环境安装。有一个常见做法值得提一下先在纯 CPU 环境里把小批量推理跑通再切 GPU 训练。BERT-base 中文模型在 CPU 上完成一次完整训练非常慢但验证数据格式是否正确时 CPU 反而更快能更快暴露出数据预处理的问题。2.2 BIO标注与BERT输入对齐词表切分导致的标签错位怎么解决NER 数据集的原始标注通常长这样一个字或一个词对应一个 BIO/BIOES 标签。B-PER 表示人名开始I-PER 表示人名中间或结尾O 表示非实体。先把标签转成数字常见做法是建立label2id映射建议统一用下面这份小表避免后面评估脚本和训练脚本各写一套标签id含义O0非实体B-PER1人名开始I-PER2人名内部B-LOC3地名开始I-LOC4地名内部B-ORG5机构名开始I-ORG6机构名内部这里没有引入 B-LOC/I-LOC 之外的时间和数量实体因为很多中文项目的第一版只需要人名、地名、机构名三类。如果业务里还有日期、金额、药品名按同样规则继续往后排 id 就行模型层不需要改结构只需要改输出维度。接下来说明 BERT 输入对齐的难点。BERT 用的是 WordPiece 切词一个中文词可能被切成多个子 token一个英文单词可能被切成un ##ion这样的碎片。此时原始标签是给整词打的不能直接搬进 BERT 输入需要按切分结果复制标签from transformers import AutoTokenizer tokenizer AutoTokenizer.from_pretrained(bert-base-chinese) def encode_example(words, tags, label2id, max_len128): tokens, labels [[CLS]], [label2id[O]] for word, tag in zip(words, tags): pieces tokenizer.tokenize(word) if not pieces: pieces [[UNK]] tokens.extend(pieces) labels.extend([label2id[tag]] * len(pieces)) tokens, labels tokens[:max_len - 1], labels[:max_len - 1] tokens.append([SEP]) labels.append(label2id[O]) input_ids tokenizer.convert_tokens_to_ids(tokens) attention_mask [1] * len(input_ids) return { input_ids: input_ids, attention_mask: attention_mask, labels: labels }这段代码的核心逻辑是先给每个原始词做 tokenize再把原标签按len(pieces)份复制到每个子 token 上。比如“北京市”被切成[北, 京, 市]原始标签B-LOC就会变成三个[B-LOC, B-LOC, B-LOC]这样标签序列长度和输入序列长度严格一致。截断时要把tokens和labels同时截断否则后面计算损失时序列长度对不上报错信息通常非常隐晦。需要额外注意attention_mask的问题。批量训练时序列长度不一要按 batch 内最长序列做 paddingpad 位置的attention_mask要置 0pad 部分的labels置-100或者label2id[O]。使用-100的好处是 PyTorch 的CrossEntropyLoss自带忽略该位置的机制计算损失时不会误伤 pad 区。BERT 为每个样本自动加上的[CLS]和[SEP]两个位置标签固定为O这两个位置对实体识别没有实际贡献但转移矩阵会用到它们CRF 章节会细说。3. BERT-BiLSTM-CRF模型结构与PyTorch代码实现三层各管什么3.1 BERT编码器直接用AutoModel还是走PreTrainedModel自定义读 PyTorch 官方文档和社区教程时会发现BERT本体和NER头之间的关系可以有很多种写法但核心选型只有一个加载bert-base-chinese预训练权重然后拿最后一层隐藏状态作为每个 token 的表示。李沐老师讲BERT训练时反复强调的一点是预训练模型已经完成了大量通用语义的编码下游任务只需要在它的表示上接一个薄层做适配。from transformers import BertModel class BertEncoder(nn.Module): def __init__(self, model_namebert-base-chinese): super().__init__() self.bert BertModel.from_pretrained(model_name) def forward(self, input_ids, attention_mask, token_type_ids): outputs self.bert( input_idsinput_ids, attention_maskattention_mask, token_type_idstoken_type_ids ) return outputs.last_hidden_state代码里outputs.last_hidden_state的形状是[batch_size, seq_len, 768]这就是后续所有层的输入。参数说明里有一个很容易踩的坑from_pretrained会自动下载权重到本地缓存如果网络不好导致下载失败可以先手动下载 pytorch_model.bin 放进本地目录然后把model_name换成那个目录的路径from_pretrained照样能读。这里不推荐把 BERT 整个冻住再训下级模型因为在中文 NER 上微调整个 BERT 带来的增益远大于只训 BiLSTM 和 CRF。常规做法是设置较低的学习率微调 BERT比如 BERT 参数学习率设为 2e-5BiLSTM 和 CRF 参数学习率设为 1e-3 或 5e-4两边差异在一个量级以上。3.2 BiLSTM层768维向量为什么还要再过一遍双向网络很多人会问BERT 的输出已经很强了为什么还要接 BiLSTM原因有两点。第一BERT 计算的是全局上下文而 NER 这类序列标注任务还依赖局部窗口特征比如“李”后面跟“小”还是“总”BiLSTM 对局部序列模式更敏感。第二768 维向量直接送到分类器会让参数量过于庞大BiLSTM 先把维度压到隐藏层大小再拼接双向结果有效减少全连接层规模并融入序列前后关系。import torch.nn as nn class BiLSTMLayer(nn.Module): def __init__(self, input_size768, hidden_size256, num_layers2, dropout0.1): super().__init__() self.lstm nn.LSTM( input_sizeinput_size, hidden_sizehidden_size, num_layersnum_layers, bidirectionalTrue, dropoutdropout, batch_firstTrue ) self.dropout nn.Dropout(dropout) def forward(self, x): out, _ self.lstm(x) return self.dropout(out)hidden_size256、num_layers2是 BERT-BiLSTM-CRF 项目里最常用的默认参数。双向 LSTM 最后输出的维度是hidden_size * 2 512因为每个位置拼接了前向和后向两个隐藏向量。batch_firstTrue表示输入形状是[batch, seq_len, input_size]和 BERT 的输出形状一致省去转置。num_layers2时第一层 LSTM 的输出会作为第二层的输入dropout只有在层数大于 1 时才生效这个细节容易让人困惑。整个模型随后接一个线性层self.fc nn.Linear(hidden_size * 2, num_tags)把 512 维映射到标签数量上得到每个 token 在 7 个标签上的发射分数emission score。到这里为止模型输出是一个[batch_size, seq_len, num_tags]的张量。如果直接用argmax取最大分数对应的标签边界上很容易出现“B-PER 后面跟了 I-LOC”这种非法跳转因为没有任何机制约束标签转移。这就是 CRF 层存在的理由。3.3 CRF层手动实现转移矩阵与Viterbi解码的取舍条件随机场在 NER 里做的是全局序列建模。它不只关心每个 token 自己的标签得分还学习标签之间的转移得分。比如“B-PER 后面跟 B-PER”的概率应该极低“B-PER 后面跟 I-PER”的概率应该很高。这些转移参数可以训练也可以在预测时用 Viterbi 解码求出整体概率最高的合法路径。手写一个最小可用的 CRF 实现可以帮助理解生产项目里通常直接用pytorch-crf这个 PyTorch 扩展库from torchcrf import CRF class BertBiLSTMCRF(nn.Module): def __init__(self, bert, lstm_hidden256, num_tags7): super().__init__() self.bert bert self.bilstm nn.LSTM( self.bert.config.hidden_size, lstm_hidden, num_layers2, bidirectionalTrue, dropout0.1, 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, labelsNone): outputs self.bert(input_idsinput_ids, attention_maskattention_mask) seq_out outputs.last_hidden_state lstm_out, _ self.bilstm(seq_out) logits self.fc(lstm_out) if labels is not None: return -self.crf(logits, labels, maskattention_mask.bool()) return self.crf.decode(logits, maskattention_mask.bool())torchcrf里的CRF类自带转移矩阵、损失函数和 Viterbi 解码loss -self.crf(logits, labels, mask)返回的是负对数似然损失所以训练时要取负号。maskattention_mask.bool()用来告诉 CRF 哪些位置是真实 token、哪些是 padding避免把 padding 位置的标签也当作统计对象。self.crf.decode(logits, mask)在推理时返回一个列表每个元素是该句子的预测标签序列长度等于句子真实长度不用手动裁剪 padding。CRF 对发射分数很敏感建议把 BiLSTM 层的输出做 LayerNorm 再送入self.fc否则训练初期容易出现“所有标签都预测为 O”的现象。下面把这个模型和它的三种变体放在一起对比方便判断自己项目里是否需要完整的三层结构模型结构优点缺点适合场景BERT softmax最简单训练快标签边界可能非法快速基线、数据量小BERT BiLSTM softmax局部信息更充分仍无法约束标签转移长文本、需要捕捉局部窗口BERT CRF约束全局转移缺少局部序列建模实体类型少、边界规范BERT BiLSTM CRF兼顾局部特征与全局约束参数和训练时间增加绝大多数中文 NER 业务从表中可以看出BERT BiLSTM CRF 并不总是最优解。数据量低于几千条时直接把 pre-trained BERT 微调再接 CRF往往比硬套三层结构更容易收敛。BiLSTM 的价值在长文本和实体嵌套场景里更明显CRF 的价值在标签类型多、人工标注边界不规整的场景里更明显。如果项目说明书里明确标注了 BERT-BiLSTM-CRF说明作者已经在多组实验里验证了这个结构在你的数据集上的收益照原结构复现即可。4. 训练与验证的关键参数从学习率到实体级F14.1 手动训练循环与混合精度不依赖Trainer也能控制每一步训练脚本建议不用 Hugging Face Trainer而是自己写训练循环。这样每一步都能显式控制梯度裁剪、混合精度、学习率调度调试时代码的可观测性更高。PyTorch 2.8 的 AMP 接口已经非常稳定能显著降低显存占用适合大多数 BERT 类模型from transformers import AdamW, get_linear_schedule_with_warmup optimizer AdamW(model.parameters(), lr2e-5, weight_decay0.01) total_steps len(train_loader) * num_epochs scheduler get_linear_schedule_with_warmup( optimizer, num_warmup_stepstotal_steps * 0.1, num_training_stepstotal_steps ) scaler torch.cuda.amp.GradScaler() for epoch in range(num_epochs): model.train() for batch in train_loader: input_ids batch[input_ids].to(device) attention_mask batch[attention_mask].to(device) labels batch[labels].to(device) optimizer.zero_grad() with torch.cuda.amp.autocast(): loss model(input_ids, attention_mask, labels) scaler.scale(loss).backward() scaler.unscale_(optimizer) torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm5.0) scaler.step(optimizer) scaler.update() scheduler.step()这段代码里有三个关键参数。lr2e-5是 BERT 本体参数的学习率如果同时要训练 BiLSTM 和 CRF 参数可以把它们单独放进一个参数组设置成 1e-3。weight_decay0.01只对非 bias 和非 LayerNorm 的参数生效能减少过拟合。max_norm5.0是梯度裁剪的阈值训练 BERT 类模型几乎必须加这一步否则偶发的大梯度会让损失直接变成 NaN。scaler.unscale_(optimizer)要在clip_grad_norm_之前调用这样裁剪作用的是真实梯度而不是被缩放过的梯度顺序写反梯度裁剪会失效。训练轮数方面中文 NER 数据集通常 3 到 10 轮之间效果最好。如果训练集只有几千条3 轮左右 BERT 层就已经充分适配如果数据有几万条可以观察到第 5、6 轮 F1 才开始稳定。每轮结束建议保存一次 checkpoint文件名带上 epoch 和验证集 F1方便后续回滚到表现最好的轮次。4.2 实体级评估seqeval怎么算F1边界错了算不算错序列标注任务不能用整体 token 准确率来做最终判断因为非实体 O 占了绝大多数模型把所有 token 都预测为 O 也能拿到 90% 以上的 token 准确率但一个实体都抽不出来。正确做法是使用实体级评估只有实体的边界和类型都完全正确才算一个正确预测。seqeval就是干这个的from seqeval.metrics import f1_score, classification_report from seqeval.scheme import IOB2 true [[B-PER, I-PER, O], [O, B-LOC, I-LOC]] pred [[B-PER, I-PER, O], [O, B-LOC, I-PER]] print(f1_score(true, pred, schemeIOB2, modestrict))这里schemeIOB2表示标签体系的标准形式modestrict表示严格要求实体边界和类型全部正确才算对不设置该参数时部分实体类型的错误可能被宽松计分掩盖。classification_report会输出每种实体类型的精确率、召回率和 F1做错误分析时重点看哪个实体类型召回率低。人名实体经常召回率高于精确率机构名则相反因为机构名长度更长、边界更不确定CRF 的转移矩阵很难稳定学会其中的内部结构。验证阶段需要把模型输出的logits先用self.crf.decode(logits, mask)得到标签 id 序列再经过id2label映射回字符串最后过滤掉 padding 部分再传给 seqeval。这类代码逻辑不长但必须写对标签序列要按每个句子独立对齐不能把整个 batch 的标签拼接成一个长序列否则 seqeval 会把句子边界当作实体边界来算直接导致 F1 虚高。4.3 PyTorch训练中常踩的CUDA与显存坑PyTorch 环境搭建和训练过程中最常遇到的问题基本集中在 CUDA 适配与显存管理上。共享 GPU 服务器上用torch.cuda.set_device(gpu_id)指定空闲显卡避免默认 0 号卡被占满。显存不够时优先做三件事把max_len从 512 降到 256、把batch_size降到 8 或 4、开启混合精度。如果单卡还是不够再用DataParallel包一层model nn.DataParallel(model)注意DataParallel包装后模型命名空间里会多出module.前缀保存模型权重之后再加载非原始结构调用会有细微差异。更实用训练经验是loss 在第一个 batch 就出现 NaN优先检查标签和 logits 维度是否为[batch, seq_len]和[batch, seq_len, num_tags]再用attention_mask.bool()确认 mask 类型。CRF 层要求标签不是torch.long之外的类型如果用torch.int也会报错。5. 模型导出与错误样本回看把zip项目变成可迭代的基准线从项目说明文档里拿到源码之后第一件事不是直接跑训练而是把数据和标签映射固定成版本。模型训练好之后保存的不仅仅是model.pt还需要把label2id、max_len、预训练模型名、训练集统计信息存成 JSONpython -c import json; json.dump({label2id: {O: 0, B-PER: 1, I-PER: 2}, max_len: 128, bert_name: bert-base-chinese}, open(config.json, w, encodingutf-8), ensure_asciiFalse, indent2)推理时用torch.load加载权重后要按state_dict里的 key 名挨个核对。通常 BERT-BiLSTM-CRF 模型的 key 会包含bert.、bilstm.、fc.、crf.四个前缀上述 config 里有bert_name和label2id才能保证模型权重和配置文件始终对应。如果从 zip 项目里拿到的 PyTorch 模型是旧版本训练的加载时要指定map_locationcpu先转出来再搬到 GPU避免本地没有原训练时显卡型号导致反序列化错误。另一个提升迭代效率的技巧是准备一个独立的小型验证脚本。用训练集里随机抽出的 200 条样本跑一轮推理把模型输出按实体 span 拆开逐条打印原句、真实实体、预测实体和错误类型。常见错误模式有三种边界侵入比如把“北京市”预测成“北京”; 标签冲突比如 “B-ORG” 后面直接接 “I-PER”实体丢失即召回率低。把前 20 条错误样本人工扫一遍往往能直接看出训练集标注标准是否前后一致这比调整任何超参数都重要。最后一招是搭建一个可以持续对比的基线流程。把当前 BERT-BiLSTM-CRF 的验证集 F1 固定在文档里后续每次改数据集清洗规则或标注规范都重新跑一遍同一套评估代码。NER 项目的后续迭代里数据质量对最终效果的影响远远大于模型结构改动长期维护这套 PyTorch 项目时最高收益往往来自把数据 pipeline 做成可复现而不是反复调整 CRF 的转移矩阵或 BiLSTM 的隐藏层大小。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联 返回资讯列表 →