
简介一份面向Python开发与自然语言处理学习者的实战资源聚焦利用预训练BERT模型完成中文命名实体识别NER从模型原理、数据准备到微调评估形成完整闭环。压缩包共9个文件以Python脚本、中文文本数据集、评估脚本和说明文档为主涵盖训练/开发/测试样本、词表、模型实现及评估工具同时附有README使用说明与实验效果图整体仅3.72MB轻量易部署便于快速复现。已有3342人学习内容深入讲解BERT双向Transformer结构、MLM与NSP预训练机制、IOB标注体系及Precision/Recall/F1评估指标并针对中文分词、字符编码等特殊问题给出可落地的处理方案。评估模块支持实体级精确率、召回率与F1分数自动计算方便校验模型性能。借助Hugging Face Transformers库读者可逐行理解参数调优、损失函数与优化器选择直接修改数据集即可迁移到人名、地名、组织名等实体识别任务适合希望在中文语料上实践BERT的NLP初学者和进阶开发者。1. BERT做中文NER预训练模型给序列标注带来的变化Python开发里自然语言处理方向最常被问的一道题就是中文命名实体识别。早几年做中文NER主流方案还是BiLSTMCRF自己训练字向量、调CRF转移矩阵跑一趟下来很折腾。后来自从预训练BERT普及情况完全变了直接加载一个中文BERT在标注数据上做微调效果比传统模型高出一截。这篇笔记围绕“Python 预训练语言模型BERT实现中文NER”这个项目展开说的是从数据准备、标签对齐到训练、评估和落地预测的完整流程。项目不是纯理论偏重能直接复现的代码和参数设置适合正在做信息抽取、文本清洗或急于给业务加一个实体识别接口的开发者。如果你已经受够了人工写正则抽实体看完这篇可以直接把流程搬到自己的数据集上。2. 数据构造与标签对齐BIO标注和BERT分词器的协同工作2.1 BERT做中文NER的原理与选型命名实体识别本质上是序列标注给句子里的每个字或词打一个标签标出人名、地名、机构名等实体的边界。中文里“北京”是地名“北京市朝阳区”是一个完整的地名还是两个实体取决于标注规范。项目里用的是最常见的BIO标注法B表示实体开头I表示实体内部O表示非实体。选预训练BERT而不是从头训练BiLSTM核心原因是中文上下文表示。BERT在预训练阶段见过大量中文语料对“华”在“清华大学”和“人名“张华”里的语义区别已经建模。这个能力对NER非常关键因为很多错检都来自同一个字在不同上下文里被误判成不同实体。这个项目里选的是中文BERT模型输入的粒度是字符级不需要先分词。中文分词本身对NER是一个误差放大器一旦切词错误实体边界就会跟着错所以用字级输入更稳妥。标签体系只用BIO没用BIOES原因很简单少两个标签标签类别不平衡会好一些代码也更好写。模型结构上BERT后面接一个全连接层输出每个token在各个实体类别上的概率也就是BERTForTokenClassification的标配做法。不加CRF也能跑数据量小的时候主要瓶颈是上下文表达而不是标签转移约束。2.2 原始标注如何转成BIO标签标注工具导出的数据通常是实体列表包含实体类型和起止位置。把这些span转成token级别的BIO标签是整个流程的第一步。# convert_span_to_bio.py def span_to_bio(tokens, entities, max_lenNone): 把标注好的实体span转成BIO标签序列 tokens: 分好字的列表例如 [北, 京, 的, 张, 三] entities: [(start, end, entity_type), ...]左闭右开 if max_len is None: max_len len(tokens) labels [O] * max_len for start, end, etype in entities: if start max_len or end max_len: continue labels[start] B- etype for i in range(start 1, end): labels[i] I- etype return labels这段代码做的事情很简单每一条实体记录把起始位置标成B中间位置标成I。start和end通常来自标注工具的字级下标注意是左闭右开区间比如“北京”在句子“北京的张三”里start0end2那么下标0是B-LOC下标1是I-LOC。我在处理真实数据时遇到过一个问题标注工具导出的下标偶尔会越界特别是清洗掉空格之后原文本和标注文本对不齐。所以转换前最好先强制检查start和end是否落在token长度范围内越界的直接跳过而不是报错否则一条脏数据就能把整个训练流程带崩。2.3 标签分布统计与数据集划分中文NER数据里O标签通常占90%以上实体标签分布极不平衡。如果不看分布直接训练模型很可能把所有token都预测成OF1看起来还行实体一个没找到。所以划分数据集之前至少要知道每个标签出现了多少次。# split_dataset.py import random from collections import Counter def split_dataset(samples, train_ratio0.8, seed42): 按固定随机种子切分 train / dev / test samples: 每个元素是包含 tokens 和 labels 的 dict shuffled samples.copy() random.Random(seed).shuffle(shuffled) train_end int(len(shuffled) * train_ratio) dev_end train_end int(len(shuffled) * 0.1) train shuffled[:train_end] dev shuffled[train_end:dev_end] test shuffled[dev_end:] dist Counter() for item in train: dist.update(item[labels]) print(训练集标签分布:, dist.most_common()) return train, dev, testseed固定成42是为了让每次跑出来的划分结果一致。如果seed不固定同样的代码两次运行可能得到不同验证集后续比较模型效果就说不清了。训练集、验证集、测试集比例建议8:1:1数据量少于5000句时不要做9:1:0验证集太小指标波动会很大。标签分布统计这里重点关注B-ORG和I-ORG是不是远少于B-PER。如果某些实体类别只有几十个样本训练时需要考虑类别权重或数据增强否则这些实体基本学不出来。3. 微调训练与模型评估把BERTForTokenClassification跑起来3.1 加载预训练模型与tokenizer项目里模型部分直接用transformers库封装好的BERTForTokenClassification不需要手动拼BERT和分类层。加载的时候要注意num_labels必须和当前任务的标签数量一致。# init_model.py from transformers import BertTokenizer, BertForTokenClassification model_name bert-base-chinese label2id { O: 0, B-PER: 1, I-PER: 2, B-ORG: 3, I-ORG: 4, B-LOC: 5, I-LOC: 6, } id2label {v: k for k, v in label2id.items()} tokenizer BertTokenizer.from_pretrained(model_name) model BertForTokenClassification.from_pretrained( model_name, num_labelslen(label2id) ) print(初始化完成标签数:, len(label2id))这里建议把label2id单独放在项目的config.py里而不是散落在各个脚本中。加载模型时传入num_labels7如果漏传会默认使用预训练模型原始的分类头输出维度对不上训练起步就会报错。另外from_pretrained的model_name参数除了可以填模型名也可以填本地路径。如果团队里模型文件已经下载到内网model_name改成类似./models/bert_chinese这种目录就会从本地加载不再走网络。这对离线部署非常有用后面避坑章节会展开说。3.2 标签对齐tokenizer会在句首句尾插入标记中文BERT是按字切分的看似简单但tokenizer会在句首加[CLS]句尾加[SEP]padding部分还会被塞进一堆[PAD]。# encode_sample.py def encode_sample(text, bio_labels, tokenizer, max_len128): 把原始文本和BIO标签编码成模型输入 返回的 labels 中需要忽略的位置设为 -100 enc tokenizer( text, truncationTrue, max_lengthmax_len, paddingmax_length ) input_ids enc[input_ids] label_ids [-100] * len(input_ids) word_ids enc.word_ids() # 还原每个token对应原始文本中的字下标 for idx, word_id in enumerate(word_ids): if word_id is None: continue if word_id len(bio_labels): label_ids[idx] label2id[bio_labels[word_id]] else: label_ids[idx] -100 return { input_ids: input_ids, attention_mask: enc[attention_mask], labels: label_ids, }word_ids是tokenizer提供的一个字段作用是把每个token映射回原始文本的下标。word_id为None的位置是[CLS]、[SEP]和[PAD]这些位置没有真实标签必须设为-100。训练时损失函数会忽略-100位置只计算真实token的loss。很多人在这个环节翻车是因为直接把bio_labels从0开始一路填到input_ids长度效果是把[CLS]位置也当成B标签来训练。这样的模型训练时loss能降但推理时[CLS]上总带一个实体标签最终实体拼接乱七八糟。正确做法就是上面的word_ids映射任何非原始文本token都不参与计算。3.3 训练参数与训练循环训练BERT做NER常用框架里最省事的是直接用Trainer。它内部已经处理了batch收集、梯度更新、验证循环等逻辑只需要关心参数配置。参数常用值说明learning_rate3e-5BERT微调一般用2e-5到5e-5太大导致灾难性遗忘per_device_train_batch_size16显存紧张时降到8或4per_device_eval_batch_size32验证集不更新梯度可以略大num_train_epochs3数据少时2到3轮过多会过拟合weight_decay0.01对分类头以外的层做轻度正则max_len128超过截断长文本需调整到256或512# train.py from transformers import Trainer, TrainingArguments training_args TrainingArguments( output_dir./ner_checkpoints, learning_rate3e-5, per_device_train_batch_size16, per_device_eval_batch_size32, num_train_epochs3, weight_decay0.01, evaluation_strategyepoch, save_strategyepoch, load_best_model_at_endTrue, metric_for_best_modelf1, greater_is_betterTrue, ) trainer Trainer( modelmodel, argstraining_args, train_datasettrain_dataset, eval_datasetdev_dataset, tokenizertokenizer, compute_metricscompute_metrics, ) trainer.train()learning_rate这个参数是微调BERT最敏感的一个。我用3e-5起步如果训练集很小只有几千句降到2e-5更稳否则模型可能把原来预训练学到的语言知识冲掉。batch_size影响不大但太大时显存溢出建议按最大长度128加batch_size16先跑通再逐步加大。save_strategyepoch配合load_best_model_at_endTrue会在每个epoch结束保存一次训练结束后自动加载验证集F1最好的那一个检查点。如果不加这两个配置最终保存的可能是最后一个epoch的模型而不是表现最好的模型。3.4 评估指标与模型保存NER的评估和普通文本分类不太一样它是按实体级别算的不是按token算的。token级别正确但实体边界差一个字也算整个实体预测失败。# evaluate.py from seqeval.metrics import f1_score, classification_report def compute_metrics(pred): predictions, true_labels pred.predictions, pred.label_ids pred_tokens predictions.argmax(-1) true_seq, pred_seq [], [] for i in range(len(true_labels)): cur_true, cur_pred [], [] for j in range(len(true_labels[i])): if true_labels[i][j] -100: continue cur_true.append(id2label[true_labels[i][j]]) cur_pred.append(id2label[pred_tokens[i][j]]) true_seq.append(cur_true) pred_seq.append(cur_pred) return { f1: f1_score(true_seq, pred_seq), report: classification_report(true_seq, pred_seq), }compute_metrics接收模型输出和一个label_ids数组。label_ids里-100的位置需要先过滤掉再把数字标签转换成类别名字符串。seqeval计算F1时会同时看实体的类型和边界漏掉一个内部token整个实体就算没对上。训练结束保存模型时模型权重和tokenizer要一起保存否则换机器加载时容易出现词表不一致# save_model.py model.save_pretrained(./final_ner_model) tokenizer.save_pretrained(./final_ner_model) print(模型已保存到 ./final_ner_model)保存目录下面会有配置文件、词表文件和模型权重文件。下次加载时直接用同一个目录名保持词表和训练时一致这一点非常重要。4. 避坑指南训练和预测阶段常见的五个翻车现场4.1 标签错位loss很低但F1上不去现象训练集loss降到0.1以下验证集F1只有0.4左右很多实体完全没有被识别出来。原因把BIO标签直接按顺序塞进input_ids忽略了tokenizer在句首句尾插入的[CLS]和[SEP]。尤其是带padding的时候标签序列被padding打乱模型学到的是错位映射。解决编码样本时统一用word_ids映射把非原始token位置的标签设为-100。我一般会在训练前打印前5条样本的input_ids和labels人工看一眼首尾位置是否对齐。这个检查只需要两分钟能省后面调试的好几个小时。4.2 文本清洗不一致原始文本和标签对不上现象训练集和验证集都来自同一个文件但验证时实体全部错位尤其是包含空格和换行的长文本。原因数据预处理阶段把文本里的空格、换行去掉了但标注工具导出的实体下标还是基于清洗前的原文。两边文本不一致所有start和end全部偏一位。解决清洗动作要在数据进入标注工具之前完成或者在导出后重新基于清洗文本刷新一遍实体下标。项目里的建议是准备数据时保证“一句文本对应一条标签序列”任何正则清洗都放在BIO转换之前做清洗完不要再去改动原始句子。4.3 标签维度不匹配加载预训练模型直接报错现象执行model(**batch)的时候报错提示logits的维度是2但标签的维度是7。原因summarize方法里label2id有7个类别但加载模型时没传num_labelsBERT默认用了预训练头。例如预训练模型头输出2个分类训练标签却是7类维度自然对不上。解决加载模型的脚本里统一从config.py导入label2id然后from_pretrained时显式传num_labelslen(label2id)。我还习惯在训练前打印一行model.config.num_labels确认它等于7。一旦发现不等于第一步别看代码先查num_labels传没传对。4.4 实体类别不均衡B-ORG始终学不出来现象训练后PER类实体识别效果还不错ORG类几乎全军覆没验证集报告里B-ORG的recall是0。原因实体类型样本量差距悬殊比如PER有8000条ORG只有200条模型把所有注意力都放在高频实体上低频实体被O标签带偏。解决常见做法有两种。第一种是给损失函数加权重低频实体对应标签的loss权重调大# weighted_loss.py import torch class_weights torch.tensor( [1.0, 1.0, 1.0, 3.0, 3.0, 2.0, 2.0], dtypetorch.float ) loss_fct torch.nn.CrossEntropyLoss(ignore_index-100, weightclass_weights)第二种是增加数据。低频实体只有200条时可以把包含这些实体的句子复制几份同时做一些字符级替换增强实体本身不变周边上下文变化。比例上我一般控制在实体标签至少超过500条。4.5 预测结果还原不到原文位置现象推理阶段拿到了B-PER、I-PER等标签想回填到原始文本里发现字符对不上英文和数字尤其明显。原因推理时没有保存token与原始文本的偏移量。BERT tokenizer会把“iPhone14”拆成多个token仅凭token序号无法知道它对应原始文本里哪个字符区间。解决推理时让tokenizer返回offset_mapping# predict_prepare.py enc tokenizer( text, return_offsets_mappingTrue, truncationTrue, max_length128 ) offset_mapping enc[offset_mapping]有了offset_mapping预测出的每个标签都能找到它在原文本里的起始和结束字符位置。尤其是中文混排英文的场景这一步不能省。5. 进阶从实体识别结果到可部署的Demo5.1 写一个可交互的预测函数训练完成不是终点能直接在现场文本上跑出实体才是落地。写预测函数时不要复用训练时的编码函数因为训练时要返回labels推理时只要input_ids、attention_mask和offset_mapping。# predict.py def extract_entities(text, tokenizer, model): model.eval() with torch.no_grad(): inputs tokenizer( text, return_tensorspt, truncationTrue, max_length128, return_offsets_mappingTrue ) offsets inputs.pop(offset_mapping)[0] logits model(**inputs).logits[0] pred_ids logits.argmax(dim-1) entities [] cur_type None cur_start -1 for token_idx, offset in enumerate(offsets): if token_idx 0 or token_idx len(offsets) - 1: continue label id2label[pred_ids[token_idx].item()] start_char offset[0].item() end_char offset[1].item() if label O: if cur_type is not None: entities.append({ type: cur_type, text: text[cur_start:end_char], start: cur_start, end: end_char, }) cur_type None elif label.startswith(B-): if cur_type is not None: entities.append({ type: cur_type, text: text[cur_start:end_char], start: cur_start, end: end_char, }) cur_type label[2:] cur_start start_char elif label.startswith(I-): if cur_type is not None: end_char end_char if cur_type is not None: entities.append({ type: cur_type, text: text[cur_start:len(text)], start: cur_start, end: len(text), }) return entities这种按token扫描的方式核心逻辑是维护一个当前实体游标。遇到B标签开新实体遇到I标签判断是否属于当前实体遇到O或新B标签时把旧实体收尾。直接用offset_mapping里的start和end字段不需要自己数下标中文和英文混合文本都不会错位。5.2 从纯BERT到BERTCRF当数据量逐步增大纯BERT输出softmax的边界连续性会成为一个瓶颈。BERT没有显式建模标签之间的转移概率预测结果偶尔会出现“B-PER后面跟着I-ORG”这种明显不合理的组合。CRF层可以直接约束标签转移让预测序列符合BIO逻辑。纯BERT方案和BERTCRF方案的取舍参考下面这个对比指标BERT SoftmaxBERT CRF实现复杂度低transformers直接支持中需要额外写CRF层训练耗时短增加10%到20%实体边界一致性一般好小数据集表现稳定容易过拟合如果数据量少于5000句我建议先不上CRF。纯BERT已经能满足绝大多数场景CRF带来的提升主要在高频长实体和严格BIO约束上数据量不够时CRF反而会给转移矩阵引入噪声。5.3 我开始坚持的一个小习惯从那次标签错位翻车之后我每拿到一个新NER数据都会强制走一遍“编码样本 → 打印5条可视化结果 → 再进训练”的流程。可视化里只看input_ids、word_ids和label_ids三列能发现绝大多数对齐问题。这个动作看起来很笨但省掉的时间远超过它本身消耗的十分钟。做完对齐检查再看一眼标签分布心里有底再启动训练。希望这套流程也能帮你少踩几个坑祝顺利。本文还有配套的精品资源点击获取
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。