首页 / 资讯中心 / 文章详情

中文NER实战:基于BERT的命名实体识别训练与部署避坑指南

中文NER实战:基于BERT的命名实体识别训练与部署避坑指南 ★ FEATURED ARTICLE
简介面向希望掌握中文命名实体识别NER的 Python 开发者和自然语言处理学习者这份资源以预训练语言模型 BERT 为核心系统讲解从中文数据预处理、标记化、添加特殊令牌到模型训练、优化器选择及评估部署的完整流程并深入剖析双向 Transformer 编码器结构、IOB 标注体系与 Precision/Recall/F1 评估指标适合有一定 Python 基础并希望上手中文 NLP 实战的读者。压缩包共 9 个文件大小 3.72MB包含可直接运行的 Python 训练脚本、TensorFlow 评估模块、Perl 官方 conlleval 评估脚本、Markdown 说明文档、示意图与 4 个 txt 数据文件目录结构简洁便于边看边练。资源已获得 3335 人学习下载借助脚本和示例数据可完整复现中文 NER 的训练与验证过程同时还能了解 Hugging Face Transformers 库的加载与微调方式并学会将模型应用到自有文本的实体抽取中。总体来说这是一份理论结合实战的紧凑资源包能帮助读者快速建立中文 NER 的全栈认知并落地代码。1. 用BERT做中文NER到底比传统方法强在哪中文命名实体识别中文NER这几年几乎被BERT系模型统治了。以前做新闻实体抽取得先分词、再设计特征模板、跑CRF换一个领域就要重新调特征换一个数据集精度掉得厉害。现在用预训练语言模型直接微调BERT在中文上的优势是能靠自注意力捕捉字符级的上下文分词错误不再向下传播实体边界识别反而更稳。我见过不少项目从CRF换成BERT后F1从80左右直接跳到90上下代价只是多一张显卡和一点训练时间。这篇就按一条能跑通的路来讲模型怎么选、数据怎么整理、训练和推理怎么写、以及最容易让人翻车的五个坑适合想在自己数据集上做中文NER的Python工程师。2. 选型与前置模型选哪个、环境怎么搭、数据长什么样2.1 中文NER的模型选型BERT-base还是BERT-wwm中文领域最常见的预训练语言模型是Google原版BERT-basechinese以及哈工大讯飞联合发布的BERT-wwm系列。两者的差异核心在于预训练时的掩码策略。原版BERT是随机mask单个字BERT-wwm把整个词的所有字一起mask强迫模型通过上下文去预测整个词。对中文这种词边界模糊的语言wwm普遍在NER和分词任务上比原版高出0.5到1.5个点的F1而且推理速度几乎一样。如果项目对设备要求苛刻可以选择蒸馏版模型如TinyBERT或Alberto体积小一半以上但实体边界容易变碎尤其是组织名和地名。我一般建议先跑通流程用BERT-wwm-ext精度够、社区资料多上线换小模型。别在选型上花太多时间中文NER的收益大头在数据质量模型之间的差距远小于标注不一致带来的误差。2.2 运行环境与依赖安装开始之前先装好基础环境。用Python 3.8以上装transformers、torch、datasets这三个核心库。如果只是训练不需要额外装tf纯PyTorch版本就够。这里给一组相对稳妥的安装命令国内网络环境下建议指定国内镜像源pip install torch2.0.1 torchvision0.15.2 --index-url https://download.pytorch.org/whl/cu118 pip install transformers4.30.2 datasets2.13.1参数说明torch版本要和CUDA匹配上面给的cu118对应CUDA 11.8如果显卡驱动是CUDA 12.x改成cu121即可。transformers版本不要太新4.30左右对BertForTokenClassification的接口稳定新版会强制用AutoModel反而多一层抽象。安装后可以用python -c import transformers; print(transformers.__version__)验证。内存方面中文BERT-base模型权重约400MB加载时额外占用2-3GB内存训练时显存建议至少8GB。如果你只有CPU机器也可以训练但速度慢十倍以上建议先用小数据集验证流程。2.3 标注数据格式BIO还是BIOESNER训练数据最常见的两种标注体系是BIOBegin, Inside, Outside和BIOES多一个End和Single。BIO足够覆盖绝大多数场景BIOES在实体边界统计上更严格CRF解码时对边界约束更好但对标注员要求更高。我的建议是梯度够用就行先上BIO如果模型经常把两个相邻实体接在一起再考虑BIOES。数据格式采用按字切分的JSON或文本行推荐用JSON便于携带标签和额外信息。训练脚本里我一般用一个函数把数据集转成模型输入的字典核心是三个字段input_ids、attention_mask、token_type_ids以及和每个token一一对应的labels。中文BERT用了WordPiece但BERT中文词典大部分是单字所以一个token通常对应一个字少数字符如英文或数字会被切成子词标签对齐要特别小心。2.4 预训练模型的下载与加载transformers库提供了BertForTokenClassification这个开箱即用的类自动在BERT上接一层线性分类器。加载模型时指定num_labels它会自动初始化一个全连接层输出每个token的标签分布。常见做法是from transformers import BertForTokenClassification, BertTokenizerFast model_name hfl/chinese-bert-wwm-ext tokenizer BertTokenizerFast.from_pretrained(model_name) model BertForTokenClassification.from_pretrained(model_name, num_labels7)这段代码里model_name是HuggingFace Hub上的模型标识首次运行会联网下载权重并缓存到本地后续离线可用。num_labels7对应你数据集中BIO标签的总数包括O。如果你的实体类型是3种加上O再乘以2B和I就是7。如果你的实体类型是5种就是11。一定要数清楚标签数不对会让模型输出维度和训练数据对不上。BERT冻结与否取决于数据量。数据量少于一万条时建议冻结BERT的底层参数只训练分类头防止过拟合数据量超过五万条可以全量微调。后面训练脚本里我会给出冻结层的开关。3. 从零搭建一个中文NER训练流程数据加载到模型训练3.1 数据预处理把文本转成BERT的input_ids和label_ids这一步是整条链路里最容易出错的环节标签和token错位一个位置模型训练半天全是噪声。先定义标签到id的映射再写转换函数。这里给出一个常用的预处理函数基于transformers的encode_plus方法from transformers import BertTokenizerFast tokenizer BertTokenizerFast.from_pretrained(hfl/chinese-bert-wwm-ext) def encode_example(text, labels, max_len128): # 分词时返回 offset_mapping用于将字符位置映射到 token 位置 encoding tokenizer( text, truncationTrue, paddingmax_length, max_lengthmax_len, return_offsets_mappingTrue, return_tensorspt ) offset_mapping encoding.pop(offset_mapping).squeeze(0) # 初始化 label_ids用 -100 表示不需要计算 loss 的位置 label_ids [-100] * max_len # 将原始 BIO 标签对齐到 token 级别 char_idx 0 for token_idx, (start, end) in enumerate(offset_mapping): if start 0 and end 0: continue # [CLS]、[SEP]、padding if token_idx 0: continue # [CLS] # 单个字符token直接对应 if end start 1: label_ids[token_idx] label_to_id[labels[char_idx]] char_idx 1 else: # 英文或数字子词把子词标签设为第一个子词的标签 # 这里简单处理多个子词都沿用同一个实体标签 sub_len end - start sub_label labels[char_idx] for _ in range(sub_len): if char_idx len(labels): label_ids[token_idx] label_to_id[sub_label] char_idx 1 return { input_ids: encoding[input_ids].squeeze(0), attention_mask: encoding[attention_mask].squeeze(0), label_ids: torch.tensor(label_ids) }逻辑说明先对文本做分词并拿到offset_mapping它记录每个token在原始文本中的起止字符位置。对于中文一个token就是一个字标签直接平移。对于英文或数字BERT会把一个词切多个子词此时同一个实体标签要复制到每个子词上。如果遇到[CLS]和padding标签填-100这样PyTorch的CrossEntropyLoss会自动忽略这些位置不参与梯度计算。参数说明max_len要根据你的语料长度分布来定。一般新闻句子80-100 token就够但中文医疗文书动辄200字如果截断在128实体可能被切掉一半。建议先统计语料中句子长度的95分位数再定max_len。paddingmax_length会强制把所有样本填充到一样长浪费显存但数据加载简单入门阶段够用后面优化再用动态padding。3.2 模型结构BERT 线性分类层BertForTokenClassification的模型结构很直接BERT编码器输出每个token的768维向量BERT-base经过一个Dropout层再接一个线性层把768维映射到num_labels维。这个结构没有引入CRF推理时对每个token独立取概率最大的标签。为什么不直接上CRF因为CRF解码会考虑标签转移约束比如B-PER后面不能跟I-ORG确实能提升边界一致率。但CRF是有代价的训练变慢并且在数据量小时容易放大标注噪声。我处理过的几个项目里纯BERT的效果和BERTCRF差在1个F1点以内而且纯BERT更容易并行推理。所以第一版尽量先跑纯线性头等评估发现问题再升级。3.3 训练脚本核心代码训练部分我习惯用原生PyTorch Trainer而不是transformers自带的Trainer原因是可控。下面这段代码完整覆盖了数据加载、模型初始化、训练循环和保存import torch from torch.utils.data import DataLoader, Dataset from transformers import BertForTokenClassification, AdamW, get_linear_schedule_with_warmup class NERDataset(Dataset): def __init__(self, examples): self.examples examples def __len__(self): return len(self.examples) def __getitem__(self, idx): ex self.examples[idx] return { input_ids: ex[input_ids], attention_mask: ex[attention_mask], labels: ex[label_ids] } def collate_fn(batch): # 批内动态padding减少显存浪费 input_ids [item[input_ids] for item in batch] attention_mask [item[attention_mask] for item in batch] labels [item[labels] for item in batch] return { input_ids: torch.stack(input_ids), attention_mask: torch.stack(attention_mask), labels: torch.stack(labels) } # 训练循环 model.train() optimizer AdamW(model.parameters(), lr2e-5, correct_biasFalse) total_steps len(train_dataloader) * epochs scheduler get_linear_schedule_with_warmup(optimizer, num_warmup_stepsint(total_steps*0.1), num_training_stepstotal_steps) for epoch in range(epochs): for step, batch in enumerate(train_dataloader): outputs model(**batch) loss outputs.loss loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) optimizer.step() scheduler.step() optimizer.zero_grad() if step % 100 0: print(fepoch {epoch} step {step} loss {loss.item():.4f}) torch.save(model.state_dict(), bert_ner.pt)逻辑说明模型的前向输出是SequenceClassifierOutputloss字段内部已经按labels的-100做了mask不需要额外写损失计算。clip_grad_norm控制梯度范数不超过1.0防止梯度爆炸这在BERT微调里是标配。参数说明学习率lr2e-5是BERT微调的标准值太大容易灾难性遗忘预训练知识太小收敛慢。如果你要冻结BERT底层层需要先在初始化后手动设置model.bert.requires_grad_(False)把学习率调大一点到1e-4只训练分类头。批量大小根据显存调整8GB显存跑BERT-base建议batch_size8max_len128如果batch_size2梯度下降方差会变大训练更不稳定可以用梯度累积模拟更大batch。3.4 损失函数与CRF的选择纯BERT微调的损失函数是每个token的交叉熵之和-100位置被忽略。这种损失隐含假设每个token独立预测会导致相邻标签之间缺乏约束。例如模型可能在同一个实体内部输出B-PER I-PER O第二个和第三个token之间出现断裂。在训练集够大的情况下BERT的上下文表征能大概率学会延续实体内部标签但确实存在出错。如果要上CRF常见做法是在模型输出层后面加一个torchcrf库的CRF层把标签转移矩阵作为可学习参数。我一般这样判断要不要加CRF先看纯BERT预测出的实体里内部标签断裂的比例。如果超过5%加CRF如果低于2%不需要。这个比例可以通过写一个小脚本统计预测标签中B-X后面紧跟O或B-Y的数量来估算。4. 模型评估与推理别只用准确率要看实体级别的指标4.1 序列标注评估指标precision/recall/F1怎么按实体算很多初学者用token级别的准确率评估NER模型这非常危险。一个实体有5个字模型识别对3个字token准确率相当高但实体整体是错的。正确做法是将预测标签和真实标签按span连续相同非O标签还原成实体再做集合对比。推荐用seqeval库from seqeval.metrics import classification_report, f1_score y_true [[O, B-PER, I-PER, O], [O, B-ORG, I-ORG, O]] y_pred [[O, B-PER, I-PER, O], [O, B-ORG, I-ORG, I-ORG]] # 还原span后计算实体层面的F1 print(classification_report(y_true, y_pred, digits4))逻辑说明这里的y_true和y_pred是二维列表第一维是句子第二维是每个token的标签不包含B-X这种级别要转成原始字符串标签形式。seqeval会把连续的B-X I-X合并成一个实体然后比较边界和类型。上面例子中第二句预测的I-ORG比真实多了一个真实实体是2个字预测是3个字这个实体整体被判为错。参数说明digits4控制输出小数位数。实际项目里我还会单独统计每种实体类型的F1因为不同实体难度差异悬殊比如人名容易、组织名难。如果某个实体F1明显低回来检查标注一致性比调模型参数更有效。4.2 推理脚本与标签对齐推理时最容易踩的坑是训练时用了paddingmax_length预测时也要保持相同长度否则tokenizer输出的attention_mask会不一致。更稳的推理写法是关掉padding预测完直接取有效长度部分def predict_single(model, text): encoding tokenizer(text, return_tensorspt, truncationTrue, max_lenmax_len) model.eval() with torch.no_grad(): logits model(**encoding).logits # [1, seq_len, num_labels] pred_ids logits.argmax(dim-1).squeeze(0) # [seq_len] # 去掉 [CLS] 和 [SEP]仅保留有效token tokens tokenizer.convert_ids_to_tokens(encoding[input_ids].squeeze(0)) pred_labels [id_to_label[i] for i in pred_ids.cpu().numpy()] # 过滤掉 [CLS]、[SEP] 和 padding result [] for token, label in zip(tokens, pred_labels): if token in [[CLS], [SEP], [PAD]]: continue if label ! O: result.append((token, label)) return result逻辑说明推理时我们不关心长度对齐到训练时的max_len只需保证模型输入在BERT的512 token上限以内。输出logits的形状是[batch, seq_len, num_labels]argmax拿到每个token的预测标签id再映射回字符串标签。过滤掉特殊token后得到的列表就是字符和标签的对应关系。参数说明注意这里的encoding[input_ids]如果没有padding长度就接近真实句长。如果句子超过512 token必须截断否则BERT会报错或静默产生随机结果。实际生产环境我更推荐按句子切分或分段预测后面避坑章节展开。4.3 用训练好的模型对新句子做预测部署时加载保存的权重不需要重新初始化BERT。但要注意加载前BertForTokenClassification.from_pretrained的num_labels要和训练时一致否则会报维度错误。加载方式如下model BertForTokenClassification.from_pretrained(hfl/chinese-bert-wwm-ext, num_labels7) model.load_state_dict(torch.load(bert_ner.pt, map_locationcpu)) model.eval()参数说明map_locationcpu可以把训练在GPU上的权重加载到CPU机器上推理。线上服务如果并发较低直接用PyTorch推理就行并发高时再用ONNX导出优化BERT在ONNX Runtime上的加速比一般能达到2-3倍且不影响精度。导出格式需要静态input_ids尺寸这时要固定max_len。5. 中文NER实战中的常见问题与避坑指南5.1 长文本被BERT截断导致实体被切开现象训练和预测时一个200字的句子被截断到128实体后半部分直接被丢弃真实标签里后半段还标注着I-PER但模型看不到。推理时同样截断会导致实体只有一半甚至出现B-PER单独结束。原因BERT输入有512 token上限而且很多项目为了推理速度把max_len故意设小没有考虑语料分布。解决统计语料长度取95分位数作为max_len别拍脑袋。如果语料里长文本比例高采用滑窗切分句子的策略按长度128分块块与块之间保留10个字符的重叠重叠区域的预测取后一个块的结果。这样即便实体跨块也能通过后块捕捉到完整上下文。5.2 标签对齐错位分词偏移与padding现象训练loss不下降或者准确率卡在某个奇怪值模型不收敛。打印几个batch的预测标签发现标签全部错位比如本来应该预测B-PER的位置变成了O。原因预处理阶段没有正确处理offset_mapping英文或数字子词被分割后标签复制的逻辑写错导致标签比token少一个或多一个。另一个常见原因是使用了BERT自带的分词tokenizer而数据标注是按字符标注的二者长度不匹配。解决统一用BertTokenizerFast的return_offsets_mappingTrue永远不要用tokenize方法自己拼接标签因为offset_mapping是官方保证的字符到token映射。写一个自检函数把预测标签映射回字符后实体原文片段应该和原文字符完全对应不一致就说明对齐有问题。5.3 类别不平衡实体太少模型学不到现象非实体O占比超过90%模型全体预测为OF1为0但准确率却有90%多。训练loss下降很慢实体类别的loss几乎不变。原因交叉熵在多分类不均衡时会被多数类主导梯度方向被O类垄断。解决最常见做法是给损失函数加权。CrossEntropyLoss有一个weight参数把实体类别的权重调高。权重按类别频率的反比设置例如O的权重为0.1实体为1-2。注意权重不能太大否则会把O误分成实体F1掉的更厉害。另一个有效做法是使用带实体级别的上采样把包含实体的句子复制几遍再训练。5.4 预训练模型加载慢或下载失败现象from_pretrained报连接超时或者第一次加载要等很久执行开始时卡住。原因模型权重默认从HuggingFace海外仓库下载网络不稳定容易失败。解决在第2.4节代码里加cache_dir参数指定本地缓存目录并提前用huggingface-cli download下载。国内可以在下载命令前设置镜像环境变量export HF_ENDPOINThttps://hf-mirror.com这句是让huggingface_hub库使用镜像站下载后续from_pretrained就能走同一条路径。如果公司网络完全隔离就去有网环境把模型目录整个打包离线解压到指定cache_dir一样能加载。注意模型权重文件和配置文件要放在同一个目录from_pretrained才找得到。5.5 训练和评估时随机种子不一致现象同一条数据多次训练F1波动很大差5个点以上或者模型每次预测同一个句子结果不一样。原因训练时没有固定随机种子参数初始化、dropout、数据loader shuffle都带随机性。BERT的dropout在推理时会被关闭但训练时如果种子不同结果自然有差异。解决在训练脚本开头一行固定种子import random, numpy as np, torch torch.manual_seed(42) np.random.seed(42) random.seed(42)还要配置torch.backends.cudnn.deterministic True并把dataloader的shuffle参数设为固定seed。这样至少保证相同数据、相同超参数下两次训练结果是可复现的。6. 进阶技巧用BERTBiLSTMCRF提升边界识别6.1 为什么加BiLSTM和CRFBERT输出的是每个token的上下文表示但它缺少对本句子内标签依赖的显式建模。比如一个实体内部标签必须连续两个同类型实体不能紧挨着还不分开。BiLSTM层可以在BERT输出之上再建模一次上下文交互增强边界判断CRF层则可以强制约束标签转移规则比如B-PER后面不能接I-ORG。在标注一致性好的数据集上这个组合通常可以再提高1-2个百分点的实体F1代价是训练速度慢30%左右。需要注意的是加了BiLSTM后模型不再是一个纯分类器而是一个序列标注模型。训练时前向需要把BERT的768维输出过一遍双向LSTM再送入线性层CRF在最后计算损失。推理时不能简单取argmax要用维特比解码这个逻辑在torchcrf库里已经封好。6.2 结构修改与效果对比常见做法是写一个自定义模型类import torch.nn as nn from torchcrf import CRF class BertBiLSTMCRF(nn.Module): def __init__(self, model_name, num_labels): super().__init__() self.bert BertModel.from_pretrained(model_name) self.dropout nn.Dropout(0.1) self.bilstm nn.LSTM( input_sizeself.bert.config.hidden_size, hidden_size256, num_layers1, bidirectionalTrue, batch_firstTrue ) self.classifier nn.Linear(256*2, num_labels) self.crf CRF(num_labels) def forward(self, input_ids, attention_mask, labelsNone): outputs self.bert(input_ids, attention_maskattention_mask)[0] outputs self.dropout(outputs) outputs, _ self.bilstm(outputs) logits self.classifier(outputs) if labels is not None: loss -self.crf(emissionslogits, tagslabels, maskattention_mask.bool()) return loss else: pred self.crf.decode(logits, maskattention_mask.bool()) return pred参数说明LSTM的输入是BERT输出的768维向量输出维度是hidden_size*2因为双向LSTM把正向和反向隐藏状态拼在一起。分类层输入是512维映射到标签数。CRF的decode实现维特比解码自动找全局最优的标签序列。训练时损失函数取负对数似然所以要用-self.crf(...)。结构上是否还要保留BERT的BertForTokenClassification类不需要因为CRF损失和推理流程完全不同。在数据量少于5万条的项目里加BiLSTM的效果其实不稳定因为LSTM层参数增加容易过拟合。我的经验是先用第3章的纯BERT模型跑出基线再在基线基础上加BiLSTM。如果加了之后F1没有明显提升就砍掉保留CRF层单独试一下多数情况下CRF单独就够用。6.3 用软标签或半监督扩大训练数据标注数据永远是中文NER项目的瓶颈。一个实用的做法是弱监督用规则或外部知识库比如人名列表、地名词典先做一批自动标注作为带噪声的训练数据。把这些数据与人工标注数据混合训练模型的鲁棒性会增强因为BERT见过更多多样化的表达方式。具体实现上可以把自动标注的标签概率平滑一下即把确定性标签改成0.7/0.3的软标签让模型在边界模糊处学会更保守的预测。这种方式训练出来的模型在推理时对漏标注的实体更宽容——它不再完全依赖标注的硬边界而是学会从上下文推测。需要注意的是弱监督数据占比不要超过总数据量的30%否则噪声会反噬。我自己的习惯是把推理脚本里加一个置信度阈值输出当某个实体的平均token概率低于0.9时在结果里打一个low_confidence标记。这个标记在人工复核时很实用能快速筛出模型吃不准的样本再去补充标注。这是我认为比调参更值得投入的方向毕竟中文NER的天花板不由模型决定而由数据边界决定。希望这篇能帮你把中文NER从0到1跑通少走我当初走过的弯路。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站