中文NER实战:BERT标签对齐与数据处理全流程解析

📅 发布时间:2026/10/11 6:30:27
中文NER实战:BERT标签对齐与数据处理全流程解析
简介一份面向自然语言处理入门者与开发者的中文命名实体识别NER实战方案以预训练BERT模型为核心演示在Python环境中完成人名、地名、组织名等实体的识别与模型微调。压缩包共9个文件、3.72MB包含2个Python脚本模型训练与评估指标计算、4个txt数据文件训练/验证/测试集及词表、1个conlleval.pl评估程序、1张效果图与1份Markdown说明文档结构清晰便于对照学习。目前已有3337人学习下载。通过这份资料读者可掌握Hugging Face Transformers库的加载与调用方式理解BERT双向编码结构及中文输入的分词、特殊标记处理并能直接运行代码体验完整训练流程还可利用conlleval.pl进行精确率、召回率与F1评估可作为课程设计、论文实验或入门实践的可靠参考。1. 用BERT做中文NER真正的门槛在数据处理这一层刚开始用预训练语言模型做中文命名实体识别NER的时候我一度以为自己会被模型结构、训练技巧卡住结果真正让我卡了三天的是所有人都默认“很简单”的标签对齐那一步。在Python开发的自然语言处理项目里BERT几乎成了中文NER的默认起点它把字向量、上下文编码、微调一条链打通效果通常比BiLSTM-CRF高出一截。本文从一个可复现的合同要素抽取项目出发拆解用BERT做中文NER的完整流程——环境、数据、训练、推理、避坑适合手里有一批中文语料、想快速落地实体抽取的开发者。2. 先理解BERT如何做中文NER从序列标注到模型加载的关键机制2.1 为什么是BERT而不是BiLSTM-CRF中文NER本质是序列标注给句子里的每个字打上一个实体标签比如“张伟在北京的百度工作”就应当输出“张伟/人名、北京/地名、百度/组织名”。在BERT成为主流之前工程上最常用的方案是Word2Vec或GloVe静态词向量 BiLSTM CRF。BiLSTM负责抓上下文CRF负责约束标签之间的合法性比如“I-LOC前面必须有B-LOC”这一套在当时已经做得比较成熟。但这个方案有个绕不过去的短板静态词向量训练好之后一个词在任何句子里都是同一个向量“苹果好吃”和“苹果公司”里的“苹果”没有区分。BERT的突破在于它是动态编码同一个字或词在不同语境下会得到不同表示。更重要的是中文BERT的预训练权重是以“字”为基本输入单位天然绕开了中文分词错误传播的问题。你不需要先切词再喂给模型直接把句子按字拆开交给tokenizer就行。工程上的差别也很明显。用BERT微调NER你要做的不是设计复杂的网络结构而是处理好输入输出格式输入侧把中文句子转成token序列和attention_mask输出侧把每个token预测成一个实体标签。整个训练范式被大幅简化效果反而更好。我一般会在自己的项目里先跑一版BERT基线再决定要不要叠BiLSTM或者CRF。2.2 搭建运行环境Python、CUDA、transformers的版本选择先不说模型环境的版本组合就能劝退不少人。我在实际跑项目时最常用的一套组合是Python 3.8 PyTorch 1.13 transformers 4.30。这里不追求版本最新因为BERT微调的主流教程、网上能搜到的报错经验都集中在这一代版本上踩坑了能快速找到答案。conda create -n bert-ner python3.8 -y conda activate bert-ner pip install torch1.13.1 pip install transformers4.30.2 pip install seqeval1.2.5torch版本要和你的显卡驱动匹配。如果是NVIDIA的显卡先查看本机CUDA支持的驱动版本再选对应的torch安装命令如果是CPU环境直接把torch换成CPU版本也能跑通训练只是速度慢上几十倍。seqeval是评估NER指标F1的专用库它要求预测结果必须是BIO格式的标签串后面评估部分会用到。这里有个我踩过的坑不要在一开始就装最新的transformers版本。新版本对旧模型权重做了一些兼容性调整有时会在加载BERT权重时提示“please use BertModel.forward with return_dict”之类的信息影响不大但很烦人。资源包里的依赖清单我锁定的是上述版本按这个装能最大程度减少意外。2.3 加载中文BERT模型用几行代码跑通第一个推理环境的验证方式很简单加载中文BERT的预训练权重和对应的tokenizer对一句话做一次前向推理看看模型是否正常输出。from transformers import AutoTokenizer, BertForTokenClassification import torch tokenizer AutoTokenizer.from_pretrained(bert-base-chinese) model BertForTokenClassification.from_pretrained( bert-base-chinese, num_labels7 ) text 张伟在北京的百度公司工作 chars list(text) inputs tokenizer(chars, is_split_into_wordsTrue, return_tensorspt) outputs model(**inputs) logits outputs.logits print(logits.shape)这段代码输出logits的维度是(batch_size, seq_len, num_labels)。num_labels是实体标签的总数我按最常见的BIO体系先设成7对应的是B-PER、I-PER、B-ORG、I-ORG、B-LOC、I-LOC、O这7类。注意tokenizer传入的是字符列表而不是中文句子字符串因为bert-base-chinese是按字切分的传入字符列表可以避免不必要的预分词动作。如果这一步能顺利打印出logits的shape说明模型和tokenizer都正常工作。接下来要处理的核心问题是如何把原始语料里的字级标签对齐成模型需要的token级标签。3. 数据预处理实战把BIO标注对齐到BERT的标签映射方案3.1 中文NER语料的BIO格式与标签体系先看最典型的中文NER训练数据长什么样。它一般是一行两个字和标签句子之间用空行隔开。我采用的格式如下张 B-PER 伟 I-PER 在 O 北 B-LOC 京 I-LOC 的 O 百 B-ORG 度 I-ORG 公 O 司 O 工 O 作 O这种逐字标注的格式叫BIOB表示实体的起始I表示实体的内部O表示非实体。读数据的时候按空行把句子切分出来每一行的前半部分是字后半部分是标签。第一步得把这种原始格式转成Python的数据结构。def read_ner_data(file_path): sentences [] labels [] chars [] tags [] with open(file_path, r, encodingutf-8) as f: for line in f: line line.strip() if line : if chars: sentences.append(chars) labels.append(tags) chars [] tags [] else: parts line.split() if len(parts) 2: chars.append(parts[0]) tags.append(parts[1]) if chars: sentences.append(chars) labels.append(tags) return sentences, labels这个函数的逻辑很简单逐行读取空行代表一个句子结束非空行按空格拆成字和标签。最后返回两个列表分别是所有句子的字符列表和对应的标签列表。这样处理后一条样本就是list of chars和list of tags两者长度一定相等。标签体系在设计的时候就要统一。我的建议是尽量用紧凑的标签集比如人名、地名、组织名三类就够用的情况下不要加细分类别类别越多数据不均衡的问题越明显训练出来的模型对低频实体的召回率也会下降。3.2 字与token对齐word_ids是解决标签错位的关键这是整个资源包里最重要的一段代码。BERT的tokenizer在接收字符列表后会在开头加上[CLS]在结尾加上[SEP]还可能把某些特殊字符拆分成多个subword。如果直接按tokenizer输出序列的长度去扩展原始标签会全部错位。正确的做法是利用tokenizer返回的word_ids属性。对预处理后的每个tokenword_ids表示它来自原始输入中的哪个位置。比如[CLS]的word_id是None第一个汉字对应的token word_id是0第二个汉字是1以此类推。我们需要根据这个映射关系把字级标签扩展成token级标签并在特殊token位置填入-100。def align_labels_with_tokens(tags, word_ids): label_ids [] prev_word_id None for word_id in word_ids: if word_id is None: # [CLS] 和 [SEP] 不参与实体识别 label_ids.append(-100) elif word_id ! prev_word_id: # 这是一个新token的开始保留原始标签 label_ids.append(label2id[tags[word_id]]) else: # 同一个字被拆分出的后续subword置为-100 label_ids.append(-100) prev_word_id word_id return label_ids逐行说逻辑遍历每个token的word_id特殊位置设为-100这是PyTorch CrossEntropyLoss的默认ignore_index计算loss时会跳过这些位置。正常位置的token取对应字符的标签id同一个字被拆成多个token时只保留第一个token的标签后面的也设为-100。这里prev_word_id的初始值是None因为第一个正常token的word_id可能是0而0和None比较结果是True所以标签会被正确保留。打印一条样本检查是必不可少的环节。我会在每次数据处理后做一次对齐检查确认tokenizer输出的input_ids长度等于label_ids长度同时肉眼检查前几个token的word_id和标签是否能对上原文。chars 张伟在北京.split() # 实际应传入列表 # 这里只是演示检查逻辑 enc tokenizer(chars, is_split_into_wordsTrue) word_ids enc.word_ids() print(tokens:, tokenizer.convert_ids_to_tokens(enc[input_ids])) print(word_ids:, word_ids)如果word_ids是[None, 0, 1, 2, 3, None]而tokens是[CLS], 张, 伟, 在, 北, [SEP]说明映射关系正确。这一步多花五分钟能省去后面诊断F1为零的半天时间。3.3 构建PyTorch Dataset把预处理逻辑封装成标准流程有了对齐函数接下来把它封装成标准的Dataset类供DataLoader使用。这里需要同时返回input_ids、attention_mask和labels。from torch.utils.data import Dataset import torch class NerDataset(Dataset): def __init__(self, file_path, tokenizer, max_len128): self.sentences, self.labels read_ner_data(file_path) self.tokenizer tokenizer self.max_len max_len def __len__(self): return len(self.sentences) def __getitem__(self, idx): chars self.sentences[idx] tags self.labels[idx] encoding self.tokenizer( chars, is_split_into_wordsTrue, truncationTrue, paddingmax_length, max_lengthself.max_len, return_tensorspt ) label_ids align_labels_with_tokens( tags, encoding.word_ids() ) return { input_ids: encoding[input_ids].squeeze(0), attention_mask: encoding[attention_mask].squeeze(0), labels: torch.tensor(label_ids, dtypetorch.long) }几个参数说明里值得注意的地方truncationTrue的作用是超过max_len的部分直接截掉。paddingmax_length会把所有样本补齐到同一长度方便拼成batch。这里我习惯把max_len设为128因为大部分中文句子都在128个字以内。如果语料里长句很多或者实体经常出现在句子后半段就不能简单截断下一节单独说。align_labels_with_tokens返回的是普通Python列表长度和input_ids相同转成torch.long类型后可以直接参与loss计算。labels里大量-100不会影响反向传播因为CrossEntropyLoss默认忽略这个索引。3.4 长文本的截断策略max_len不是越大越好很多中文语料里实体并不一定只出现在句子前半段。比如一段合同条款里“甲方是某某公司”中的公司名如果出现在第130个字符之后max_len128的直接截断就会丢掉这个实体。我处理这类问题常见做法是分层处理短句80字以下直接用128截断几乎没有影响。中长句128字以上先按128字滑窗切分成多段每段之间保留上一段结尾的少量重叠字比如重叠20字保证跨窗口的实体不会被切断。切出来的每段作为独立样本训练推理时把所有窗口预测结果合并重叠部分的标签以靠前的窗口为准。def split_long_sentence(chars, labels, max_len128, overlap20): segments [] start 0 while start len(chars): end min(start max_len, len(chars)) segments.append((chars[start:end], labels[start:end])) if end len(chars): break start end - overlap return segments这个函数在数据预处理阶段调用把长句子切成多个重叠片段。overlap的作用是避免实体正好落在窗口边界而被切掉一半我一般设置20字对中文来说足够覆盖常见实体长度。注意重叠部分的标签在训练时会被计算两次对模型来说相当于重复样本实际影响很小。4. 训练与评估闭环微调参数、F1计算和推理代码4.1 配置优化器和学习率BERT微调的核心参数BERT本身的参数已经预训练过了微调时不需要从头学。学习率设置过大会破坏预训练学到的语义表示过小又收敛太慢。我一般用AdamW优化器学习率初始值在2e-5到5e-5之间这是BERT微调最常见的区间。from transformers import AdamW, get_linear_schedule_with_warmup from torch.utils.data import DataLoader train_loader DataLoader( train_dataset, batch_size16, shuffleTrue ) eval_loader DataLoader( eval_dataset, batch_size16, shuffleFalse ) epochs 5 optimizer AdamW(model.parameters(), lr2e-5) scheduler get_linear_schedule_with_warmup( optimizer, num_warmup_steps0.1 * len(train_loader) * epochs, num_training_stepslen(train_loader) * epochs )batch_size16在12GB显存上运行比较安全如果显存不够就调到8并用梯度累积。这里的scheduler采用了带warmup的线性学习率衰减前10%的训练步数里学习率从0慢慢升到2e-5再在后90%的步数里线性降到接近0。这么做的原因是预热阶段可以让模型先稳定适应新数据避免一上来就被大梯度冲乱。如果你发现训练loss下降很慢优先调大学习率而不是盲目加大epoch数量。我一般会在第2个epoch结束时看一次验证集的F1如果还在上升就继续连续两个epoch不升就提前停。4.2 训练循环从loss到参数更新的完整代码import torch def train_one_epoch(model, train_loader, optimizer, scheduler): model.train() total_loss 0.0 for batch in train_loader: input_ids batch[input_ids].cuda() attention_mask batch[attention_mask].cuda() labels batch[labels].cuda() optimizer.zero_grad() outputs model( input_idsinput_ids, attention_maskattention_mask, labelslabels ) loss outputs.loss loss.backward() optimizer.step() scheduler.step() total_loss loss.item() return total_loss / len(train_loader)模型输入有三个部分input_ids是token编号attention_mask标记真实token的位置labels是上一个章节算出来的对齐标签。BertForTokenClassification的forward方法自带loss计算底层直接用CrossEntropyLoss对每个有效位置做分类-100对应的位置自动跳过。所以训练循环非常简洁不需要自己写loss函数。如果想在CPU上跑把.cuda()去掉即可如果显存不够把batch_size调小并在循环里加上torch.cuda.empty_cache()。4.3 评估计算实体级别的F1而不是token准确率很多初学者用token级别的准确率评估这是不对的。NER是实体级任务比如“张伟”两个字模型预测成“张错误、伟正确”token准确率是50%但实际上这个实体应该算预测错了。标准做法是用seqeval库做实体级别的精确率、召回率、F1。from seqeval.metrics import classification_report def evaluate(model, eval_loader, id2label): model.eval() predictions [] true_labels [] for batch in eval_loader: input_ids batch[input_ids].cuda() attention_mask batch[attention_mask].cuda() labels batch[labels].cuda() with torch.no_grad(): logits model( input_idsinput_ids, attention_maskattention_mask ).logits pred_ids torch.argmax(logits, dim-1).cpu().numpy() true_ids labels.cpu().numpy() for i in range(len(true_ids)): pred_tags [] true_tags [] for j in range(len(true_ids[i])): if true_ids[i][j] ! -100: pred_tags.append(id2label[pred_ids[i][j]]) true_tags.append(id2label[true_ids[i][j]]) predictions.append(pred_tags) true_labels.append(true_tags) report classification_report(true_labels, predictions) print(report)这里的关键是只取labels中不等于-100的位置参与评估对应上文的word_ids映射规则。id2label是训练标签id到标签名的反向映射字典需要在训练前构造好。seqeval要求传入的是二维列表外层是每个样本内层是该样本的所有预测标签格式必须严格是BIO标签。如果打印出的F1非常低先不要怀疑模型结构回到第3.2节检查标签对齐。我见过太多人在这上面浪费时间其实问题往往出在[CLS]和[SEP]位置没有置为-100导致模型被迫去学习预测特殊token的标签学习目标本身就错了。4.4 保存模型和推理训练完怎么用起来训练结束后保存的模型权重可以直接复用。我会把模型参数和tokenizer都保存下来推理时不需要重新加载数据。model.save_pretrained(./bert_ner_checkpoint) tokenizer.save_pretrained(./bert_ner_checkpoint)推理时用同一个tokenizer处理新句子得到token级别的预测标签后再映射回原始字符。这里有一个常见做法是先用word_ids把token标签还原成字级标签再按BIO规则拼接实体。from collections import defaultdict def predict_entities(text, model, tokenizer, id2label): chars list(text) encoding tokenizer( chars, is_split_into_wordsTrue, return_tensorspt ) word_ids encoding.word_ids() input_ids encoding[input_ids].cuda() attention_mask encoding[attention_mask].cuda() with torch.no_grad(): logits model( input_idsinput_ids, attention_maskattention_mask ).logits pred_ids torch.argmax(logits, dim-1)[0].cpu().numpy() entities [] current_entity None for i, word_id in enumerate(word_ids): if word_id is None: continue label id2label[pred_ids[i]] char chars[word_id] if label.startswith(B-): if current_entity: entities.append(current_entity) current_entity { text: char, type: label[2:] } elif label.startswith(I-): if current_entity and current_entity[type] label[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这段代码的核心是遍历每个token的word_id通过word_id拿回原始字符的索引chars[word_id]保证拼接出的实体文本顺序和原句一致。B-开头新起一个实体I-开头续接当前实体遇到O则结束当前实体。注意word_id可能重复同一个字被拆成多个token时这里通过char拼接处理了这种情况不会重复添加字符。5. 避坑指南中文BERT实体识别中的四个高频坑5.1 训练loss很低但F1几乎为零标签错位问题现象训练时loss稳定下降看起来一切正常但用seqeval评估时F1只有个位数几乎等于随机猜测。原因真实标签和预测标签没有对齐。最常见的是在tokenizer生成的[CLS]和[SEP]位置直接补了数字而原标签序列的长度比token序列短下一步整条序列错位。解决用word_ids做对齐映射特殊token位置和subword后续token一律填充-100。我每次处理完新数据集都会打印一个样本的token、word_id和label三种信息肉眼确认完全匹配后再进入训练。5.2 长句尾部实体识别不全max_len截断策略问题现象验证集里短句效果很好F1到85以上但凡是超过120字的长文本后半段实体基本识别不出甚至出现半个实体的结果。原因paddingmax_length和truncationTrue配合使用超过max_len的内容被直接截断长句尾部的实体信息完全丢失。解决不要盲目把max_len拉长到512那会导致单样本显存占用暴涨、训练速度变慢。用滑窗切分长句设置overlap20字保留窗口之间的衔接信息。推理时对多个窗口的结果做简单合并重叠部分取前一个窗口的预测。5.3 预训练权重加载报错缓存目录和transformers版本问题现象第一次执行AutoTokenizer.from_pretrained(bert-base-chinese)时报错说checkpoint下载失败无法打开文件或者是路径目录格式不能加载权重。原因transformers新老版本对缓存结构不一致老版本下载的cache目录在升级后被识别为非法路径。也有可能是下载过程中断导致缓存文件不完整。解决先把transformers固定到4.30.2版本。如果已经出现缓存损坏找到用户目录下的缓存文件夹删掉对应条目重新下载。我一般会设置环境变量TRANSFORMERS_CACHE指向项目内的model_cache目录这样每次运行都从本地已知位置读取排查问题也方便。5.4 训练时报CUDA out of memory显存占用爆炸现象数据预处理没问题训练刚开始执行第一个batch就报torch.cuda.OutOfMemoryError显存直接被打满。原因batch_size和max_len设置过大加上输入序列都padding到了max_len实际有效token很少但显存按最大长度分配。解决先看batch_size是不是16以上把batch_size降到8甚至4loss仍然会震荡但至少能跑起来。更进一步的方案是使用梯度累积每4个batch累积一次梯度再做一次参数更新等效batch_size保持16但显存占用只有4。还有一种思路是一切从简先找项目里有没有已经切分好的短句数据集用短句跑通一次再处理长句。4.5 验证集和测试集的划分别让评估骗了你训练过程中我习惯预留一部分数据完全不参与训练只在最后一轮才拿出来评估一次。一个常见的隐藏坑是在建模过程中反复用测试集调参测试集信息被隐式带进了训练循环最终报告的F1虚高。资源包里的数据划分函数我固定在训练前就用固定随机种子切好train/dev/test三份dev用于每轮验证test只在最后评估一次。from sklearn.model_selection import train_test_split sentences, labels read_ner_data(data/ner_data.txt) train_s, eval_s train_test_split( list(zip(sentences, labels)), test_size0.2, random_state42 )这里random_state固定为42保证每次运行划分结果一致。如果你重复调用了三次train_test_split每次得到的数据分布都可能不同最后的F1结果也就没法复现对比。我一般会把划分好的数据持久化到磁盘上后续所有实验都用同一份训练集和同一份验证集。5. 避坑指南中文BERT实体识别中最容易翻车的四个环节5.1 训练loss很低但F1几乎为零标签错位现象训练时loss稳定下降看起来一切正常但用seqeval评估时F1只有个位数几乎等于随机猜测。原因真实标签和预测标签没有对齐。最常见的是在tokenizer生成的[CLS]和[SEP]位置直接补了数字而原标签序列的长度比token序列短造成整条序列错位。解决用word_ids做对齐映射特殊token位置和subword后续token一律填充-100。我每次处理完新数据集都会打印一个样本的token、word_id和label三种信息肉眼确认完全匹配后再进入训练。5.2 长句尾部实体识别不全max_len截断策略问题现象验证集里短句效果很好F1能到85以上但凡是超过120字的长文本后半段实体基本识别不出甚至出现半个实体的结果。原因padding和truncation以及max_length配合使用时超过max_len的内容被直接截断长句尾部的实体信息完全丢失。解决不要盲目把max_len拉长到512那会导致单样本显存占用暴涨、训练速度变慢。用滑窗切分长句设置overlap20字保留窗口之间的衔接信息。推理时对多个窗口的结果做简单合并重叠部分取前一个窗口的预测。5.3 预训练权重加载报错缓存和版本的兼容性问题现象第一次执行AutoTokenizer.from_pretrained(bert-base-chinese)时报错提示模型文件缺失或者不是预训练模型目录。原因transformers版本不一致导致缓存目录读取异常或是预先下载的模型文件被意外改动。解决先把transformers固定到和资源包一致的版本删除本地缓存目录后重新拉取权重。我一般会设置环境变量把模型缓存指到项目内部的固定目录这样不会混入其他项目临时下载的模型文件。5.4 实体边界预测漂移B-和I-标签断裂现象人名“张伟”预测成“张/B-PER 伟/B-PER”连续两个B标签导致实体被拆断。或者“北/B-LOC 京/I-LOC 市/O”整体还算对但边界少了“市”。原因模型在没有CRF约束时B和I之间的顺序关系完全靠分类器独立判断偶尔会给出非法标签序列。解决在训练阶段不引入额外复杂度但在推理后加一道规则校验如果当前token是I-PER而前一个有效token是O或者另一类型就把它纠正为O。这个小处理能小幅提升实体边界F1成本几乎为零。6. 一个实战技巧用BIO合法性检查给解码结构兜底BERT做NER时输出层没有CRF的状态转移约束因此解码出的原始标签序列可能包含一些“非法”组合比如没有B-LOC就冒出I-LOC或者前一个token是B-PER后面直接跳成I-LOC。把一个跨类型实体硬拼出来对下游关系抽取来说相当致命。我习惯在实体拼接前加一个轻量级的规则修正函数。做法是扫描一遍预测出的标签序列只保留合法的BIO关系不合法的I标签全部改成O。def fix_bio_sequence(pred_tags): fixed pred_tags[:] prev_label O for i, tag in enumerate(fixed): if tag.startswith(I-): if not prev_label.startswith(B-) and not prev_label.startswith(I-): fixed[i] O elif prev_label[2:] ! tag[2:]: fixed[i] O elif tag.startswith(B-): pass prev_label fixed[i] return fixed这个修正的逻辑分两层I标签前面必须是同类型的B或I否则改成OB标签允许出现在O或同类型I之后。实际运行效果上它能修掉大约1%到3%的边界错误这在小样本数据集上可能就是F1从87.2提到88.5的区别。注意它不能修出新的实体只是把明显非法的序列变得合理所以不会引入更多噪声。模型训练稳定后我还会在验证集上做一次错误文本的抽样分析从seqeval的classification_report里捞出每个错误类型对应的原文片段人为判断是数据标注质量问题还是模型识别能力问题。数据标注质量问题的比例如果超过30%优先回头修数据而不是加更长训练时间。这个项目真正让我学会的是BERT微调本身并不难难的是把输入输出格式和评估口径全部对齐。从那以后我每次接手新数据集都会强制走一遍“打印两个样本的token与word_id对齐表、验证一个batch能否反向传播、计算一次实体级F1”这三个动作做到位了再谈调参。希望这篇拆解能帮你少走一遍我之前走过的弯路。本文还有配套的精品资源点击获取