简介:一份面向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,主流方案还是BiLSTM+CRF,自己训练字向量、调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_len=None): """把标注好的实体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通常来自标注工具的字级下标,注意是左闭右开区间,比如“北京”在句子“北京的张三”里,start=0,end=2,那么下标0是B-LOC,下标1是I-LOC。
我在处理真实数据时遇到过一个问题:标注工具导出的下标偶尔会越界,特别是清洗掉空格之后,原文本和标注文本对不齐。所以转换前最好先强制检查start和end是否落在token长度范围内,越界的直接跳过而不是报错,否则一条脏数据就能把整个训练流程带崩。
2.3 标签分布统计与数据集划分
中文NER数据里,O标签通常占90%以上,实体标签分布极不平衡。如果不看分布直接训练,模型很可能把所有token都预测成O,F1看起来还行,实体一个没找到。所以划分数据集之前至少要知道每个标签出现了多少次。
# split_dataset.py import random from collections import Counter def split_dataset(samples, train_ratio=0.8, seed=42): """按固定随机种子切分 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_labels=len(label2id) ) print('初始化完成,标签数:', len(label2id))这里建议把label2id单独放在项目的config.py里,而不是散落在各个脚本中。加载模型时传入num_labels=7,如果漏传,会默认使用预训练模型原始的分类头,输出维度对不上,训练起步就会报错。
另外,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_len=128): """把原始文本和BIO标签编码成模型输入 返回的 labels 中需要忽略的位置设为 -100 """ enc = tokenizer( text, truncation=True, max_length=max_len, padding='max_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_rate | 3e-5 | BERT微调一般用2e-5到5e-5,太大导致灾难性遗忘 |
| per_device_train_batch_size | 16 | 显存紧张时降到8或4 |
| per_device_eval_batch_size | 32 | 验证集不更新梯度,可以略大 |
| num_train_epochs | 3 | 数据少时2到3轮,过多会过拟合 |
| weight_decay | 0.01 | 对分类头以外的层做轻度正则 |
| max_len | 128 | 超过截断,长文本需调整到256或512 |
# train.py from transformers import Trainer, TrainingArguments training_args = TrainingArguments( output_dir='./ner_checkpoints', learning_rate=3e-5, per_device_train_batch_size=16, per_device_eval_batch_size=32, num_train_epochs=3, weight_decay=0.01, evaluation_strategy='epoch', save_strategy='epoch', load_best_model_at_end=True, metric_for_best_model='f1', greater_is_better=True, ) trainer = Trainer( model=model, args=training_args, train_dataset=train_dataset, eval_dataset=dev_dataset, tokenizer=tokenizer, compute_metrics=compute_metrics, ) trainer.train()learning_rate这个参数是微调BERT最敏感的一个。我用3e-5起步,如果训练集很小只有几千句,降到2e-5更稳,否则模型可能把原来预训练学到的语言知识冲掉。batch_size影响不大,但太大时显存溢出,建议按最大长度128加batch_size16先跑通,再逐步加大。
save_strategy='epoch'配合load_best_model_at_end=True,会在每个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_labels,BERT默认用了预训练头。例如预训练模型头输出2个分类,训练标签却是7类,维度自然对不上。
解决:加载模型的脚本里统一从config.py导入label2id,然后from_pretrained时显式传num_labels=len(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], dtype=torch.float ) loss_fct = torch.nn.CrossEntropyLoss(ignore_index=-100, weight=class_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_mapping=True, truncation=True, max_length=128 ) offset_mapping = enc['offset_mapping']有了offset_mapping,预测出的每个标签都能找到它在原文本里的起始和结束字符位置。尤其是中文混排英文的场景,这一步不能省。
5. 进阶:从实体识别结果到可部署的Demo
5.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_tensors='pt', truncation=True, max_length=128, return_offsets_mapping=True ) 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到BERT+CRF
当数据量逐步增大,纯BERT输出softmax的边界连续性会成为一个瓶颈。BERT没有显式建模标签之间的转移概率,预测结果偶尔会出现“B-PER后面跟着I-ORG”这种明显不合理的组合。CRF层可以直接约束标签转移,让预测序列符合BIO逻辑。
纯BERT方案和BERT+CRF方案的取舍,参考下面这个对比:
| 指标 | BERT + Softmax | BERT + CRF |
|---|---|---|
| 实现复杂度 | 低,transformers直接支持 | 中,需要额外写CRF层 |
| 训练耗时 | 短 | 增加10%到20% |
| 实体边界一致性 | 一般 | 好 |
| 小数据集表现 | 稳定 | 容易过拟合 |
如果数据量少于5000句,我建议先不上CRF。纯BERT已经能满足绝大多数场景,CRF带来的提升主要在高频长实体和严格BIO约束上,数据量不够时CRF反而会给转移矩阵引入噪声。
5.3 我开始坚持的一个小习惯
从那次标签错位翻车之后,我每拿到一个新NER数据,都会强制走一遍“编码样本 → 打印5条可视化结果 → 再进训练”的流程。可视化里只看input_ids、word_ids和label_ids三列,能发现绝大多数对齐问题。这个动作看起来很笨,但省掉的时间远超过它本身消耗的十分钟。做完对齐检查,再看一眼标签分布,心里有底再启动训练。希望这套流程也能帮你少踩几个坑,祝顺利。
本文还有配套的精品资源,点击获取