简介面向NLP初学者与算法工程师这份实践项目以Python为编程语言围绕预训练BERT模型实现中文命名实体识别NER可解决中文文本中人名、地名、组织名等实体自动抽取与标注的落地问题。压缩包共9个文件整体约3.72MB包含Python训练脚本、tf_metrics评估脚本、conlleval.pl评测工具、词汇表、文本语料与说明文档其中txt/py覆盖数据预处理、模型训练与后处理环节png为运行效果截图md提供使用指引。目前已有3337人学习下载。内容既讲解BERT双向Transformer编码器结构与MLM、NSP预训练机制也系统梳理中文NER的IOB标注体系、Precision/Recall/F1评估指标并基于Hugging Face Transformers库演示模型加载与微调、AdamW优化器和学习率调度配套train/dev/test划分语料可直观对比效果从标记化、CLS/SEP特殊令牌、序列填充到预测输出完整呈现了中文文本处理与实体分类的项目链路便于快速迁移至金融、医疗等领域的自定义NER任务。1. 用预训练语言模型BERT做中文NER先想清楚它解决什么问题用预训练语言模型BERT做中文NER最直接的收益是不再依赖分词和规则。业务方扔过来一万条中文简历要求抽姓名、学校、专业、技能四类实体第一版用正则加词典测试集上F1有77看着挺体面一上全量数据漏召和错召同时爆炸因为真实文本里同一类实体的写法永远比规则枚举得多。换成BERT做序列标注后同样这批文本实体识别F1直接涨到91而且分词词典的维护成本几乎归零。下面按这条路线拆开讲为什么中文NER值得选BERT、数据怎么整理成它能吃的格式、最小训练脚本怎么写、以及几个大概率会让你也翻车的坑。注释标签选BIO还是BIOESmax_len到底设多大领域权重能不能直接换这些细节都是需要在一线趟过才知道的。适合手里已经有标注数据、想把识别效果从“规则能用”推到“模型好用”的从业者。2. 中文NER为什么选BERT从“分词依赖”到“字级编码”的取舍2.1 分词不再是前置依赖字级编码与错误传导传统中文NER管线里分词往往是第一道工序。句子先被切成词再给每个词打词性、判断边界最后丢给序列标注模型。问题在于分词模型本身就会出错一个词切错了后面的实体边界跟着错前缀错误一路传导到最终结果。BERT这类预训练语言模型走的是另一条路输入直接进编码器中文场景下绝大多数token就是一个汉字少数常见词会被合并成一个token。编码器通过多层自注意力让每个token看到整句话的上下文不再需要外部分词器先给一个“词边界”做前置条件。对中文来说这一步很关键因为中文的词边界本身就是个难题把它从NER任务里拆出去等于去掉一个最容易累积误差的环节。举个例子“某大学计算机学院”如果分词器把“计算机”切错传统模型的边界判断就会跟着乱而BERT直接把整句编码注意力分布会自然落在“某大学”和“计算机学院”的边界附近即使它在预训练时没见过这个组合。这也是为什么同一份数据BERT基线的F1往往比BiLSTM-CRF基线高出不少。我一般会建议只要团队里没有“必须部署到纯CPU低延迟场景”这种硬约束新项目直接考虑BERT路线别再从零训一套分词加标注的流水线。分词结果对NER带来的隐性问题不在少数——词典外的词被切成碎片规则和传统模型对碎片束手无策。BERT虽然也可能把生僻字编码成罕见token但至少能借助上下文把语义兜住这不代表它不需要标注数据只是把语言知识层面的负担转移到了预训练阶段下游只需要学任务层面的映射。2.2 微调还是冻结特征两条路线怎么选用BERT来做NER落地时先要决定参数怎么更新。冻结特征路线把BERT当作一个固定的向量提取器输出last_hidden_state给下游的BiLSTM或CRF全量微调路线直接让BERT的权重跟着NER损失一起更新。中文NER任务上全量微调几乎是默认选择原因在于任务头简单、反向传播能让BERT内部的注意力分布按任务重排而不是拿着一套通用向量硬凑。冻结特征一般在算力受限、或者样本量小到几百条的场合才会考虑优点是训练成本低缺点是BERT本身学到的语言表示无法针对实体边界调整效果通常比微调低3到8个点。两者之间的“半冻结”做法也常有人用BERT只解冻最后两层前面全部冻结或者给BERT加Lora这类低秩适配只训练一小部分参数。这类方案在数据量中等、训练资源有限的场景里是性价比很好的折中。选择时不用太纠结先把全量微调跑通拿到一个基线再去试半冻结。如果你从头就上冻结特征很可能会因为分类头随机初始化没调好把BERT本身的能力给掩盖掉。路线训练成本数据量要求中文NER典型效果冻结特征低只训下游头几百条即可启动比微调低3~8个点半冻结中只更新部分层千条级别比较稳接近全量微调全量微调高更新整个BERT几千条以上效果明显常规首选很多从业者会把“BERT输出向量再接一个BiLSTM”理解成必须的增强其实未必。BertForTokenClassification这类结构本质上就是在每个token的隐状态上加一个线性分类层已经能把上下文编码用好。BiLSTM或CRF是在这个基础上补充序列层面的约束属于迭代优化项不是第一个版本的非必要条件。第一个版本越简单越好先把数据链路跑通后面再按短板加模块。2.3 序列标注框架BIO和BIOES怎么选中文NER的落地形态几乎都是序列标注给每个token一个标签标签体系决定模型能看见的信息粒度。最常用的是BIOB表示实体开始I表示实体内部O表示非实体。以PER、LOC、ORG三类实体为例标签集合写出来是B-PER、I-PER、B-LOC、I-LOC、B-ORG、I-ORG、O共7个。BIOES在此基础上增加E实体结尾和S单字实体“张 三”会标成B-PER、E-PER一个单字实体则直接用S-PER边界信息更明确。从效果看BIOES确实让模型更容易学习边界代价是标签数量从7增到13分类头的参数和样本稀疏程度同时上升。训练数据只有一千来条时BIOES里E、S这类标签每个类别的样本量远少于B模型反而容易在低频标签上犯错。我一般的原则是数据三千条以下用BIO数据量充足且实体边界经常模糊比如地址、机构名带长修饰时换BIOES。还要明确一点BIO和BIOES都只能表示“扁平实体”。如果一个实体内部嵌套另一个实体比如“某大学计算机学院”整体是个机构内部“计算机学院”又是一个实体这套标签体系直接失效需要单独设计嵌套方案不能指望换个标签集解决。2.4 中文预训练模型选择通用权重还是领域权重新手最容易纠结的一个点到底用哪个中文BERT权重。实际经验是通用领域直接选开源的bert-base-chinese就够用模型规模在base级别单卡能训效果稳定。领域文本越专业病历、法律、金融公告越值得去找在对应语料上做过继续预训练的权重但要注意这类权重质量参差不齐必须先用一个统一的测试集对比再决定不能看着名字就换。替换权重后还要确认词表是否一致这个问题后面避坑章会单独展开。有些从业者会直接冲更大规模的预训练模型这在中文NER上未必换来更高的F1。BERT在词表覆盖和训练语料上和中文任务契合度很高而更大的参数意味着更长的训练时间、更大的显存占用收益经常只有零点几个点。先跑通链路再把模型规模当成调优手段去试是性价比最高的顺序。数据显示不理想时优先检查数据和标签不要一上来就换模型。3. 把中文语料转成BERT输入标注格式、标签对齐与预处理类3.1 三种常见标注格式从原始数据到逐字BIO拿到中文语料后首先要统一成“逐字标签”因为BERT的输入是字符序列。最常见的原始形态有两种一种是JSON格式一个句子带entities数组另一种是已经标好的逐字文件每行“字标签”空行分隔句子。逐字文件本身就是我们想要的形态JSON则要按字符偏移展开。转换代码很少但边界判断必须写对def entities_to_char_labels(text, entities): char_labels [O] * len(text) for ent in entities: start, end, etype ent[start], ent[end], ent[type] for i in range(start, end): if i start: char_labels[i] B- etype else: char_labels[i] I- etype return char_labels这段代码的核心是两个判断实体起点打B起点之后到终点之间打I。注意这里的text最好去掉换行和多余空格否则offset会错位。很多标注工具给的是Unicode偏移直接信任它通常没问题但发现实体边界落在标点符号上时多半是标注阶段的问题要在预处理里做一次边界校验而不是硬着头皮送进模型。另一类常见数据是已经标注好的逐字BIO文件读取时按行切分即可def read_bio_file(path): texts, labels [], [] cur_chars, cur_labels [], [] with open(path, encodingutf-8) as f: for line in f: line line.strip() if not line: if cur_chars: texts.append(.join(cur_chars)) labels.append(cur_labels) cur_chars, cur_labels [], [] continue char, label line.split() cur_chars.append(char) cur_labels.append(label) # 收尾文件最后一段没有空行时也要保存 if cur_chars: texts.append(.join(cur_chars)) labels.append(cur_labels) return texts, labels注意收尾那段不能省很多人只处理空行触发保存结果最后一条样本悄悄丢了。训练时样本少一两条可能看不出来但验证集如果恰好丢的是低频实体样本指标就会莫名其妙地波动。3.2 标签对齐offset_mapping是解决错位的唯一正解BERT的tokenizer不一定按字切。“某大学”三个字可能被合成一个token英文单词又可能被拆成子词。直接将逐字标签拼成token级标签长度对不上训练时必然报警或错位。常见做法是用tokenizer返回的offset_mapping它记录了每个token对应原始文本的字符区间按这个区间取标签就稳妥了from transformers import BertTokenizer tokenizer BertTokenizer.from_pretrained(bert-base-chinese) def make_input(text, char_labels, tokenizer, label2id, max_len128): encoding tokenizer( text, max_lengthmax_len, truncationTrue, paddingmax_length, return_offsets_mappingTrue, ) label_ids [] for start, end in encoding[offset_mapping]: if start end: # [CLS]、[SEP] 和 padding 的区间为空 label_ids.append(-100) else: label_ids.append(label2id[char_labels[start]]) return ( encoding[input_ids], encoding[attention_mask], label_ids, )这里有两个点值得展开。第一特殊token占用起始位置但它的offset区间为空所以判断startend就分配到-100。-100是PyTorch交叉熵损失默认的忽略索引模型只在真实token位置计算loss。第二当一个token覆盖多个汉字时取区间第一个字符的标签。如果第一个字符是B这个token就被视为实体起点符合直觉“某大学”如果被合成一个token取B-ORG训练目标就是这个token输出B-ORG。这个逻辑同样适用于英文混排场景数字、英文子词的offset映射规则是一致的不需要额外分支。3.3 固定label2id字典训练、验证、部署共用同一份label2id字典虽然可以在加载数据时现生成但我强烈建议把它固定下来。原因在于模型最后一层线性层的输出维度由标签数量决定如果中途标签顺序变了权重对应关系就乱了。实际项目里我会把字典写成JSON文件放在配置目录所有脚本统一加载import json label2id { O: 0, B-PER: 1, I-PER: 2, B-LOC: 3, I-LOC: 4, B-ORG: 5, I-ORG: 6, } with open(label2id.json, w, encodingutf-8) as f: json.dump(label2id, f, ensure_asciiFalse, indent2) id2label {v: k for k, v in label2id.items()}常见误用是让label2id按出现频次动态排序今天“O”是0明天可能就变成5模型参数一换线上预测全错。这种问题很难排查因为它不会报错只是结果全乱。另一个容易被忽略的选择是max_len。中文一句话通常不会超过64个token但病历、合同这种长句很容易超过128。max_len设太小尾部实体被截掉设太大显存浪费且训练变慢。我一般先统计语料的token长度分布选择能覆盖90%句子的长度。超过上限的句子不要简单截断而是先按逗号、句号、分号切成子句再分别送进模型保住尾部实体。4. 最小可运行的BERT中文NER训练脚本从加载权重到训练循环4.1 加载预训练权重tokenizer和模型必须成对用transformers库加载BERT权重tokenizer与模型必须来自同一个权重点或同一个本地目录。用bert-base-chinese这种通用权重时两边直接传同一个名字即可。注意num_labels必须和你的标签总数一致否则模型加载时线性层维度不匹配会直接报错。这里放一个最简加载片段from transformers import BertTokenizer, BertForTokenClassification, AdamW tokenizer BertTokenizer.from_pretrained(bert-base-chinese) model BertForTokenClassification.from_pretrained( bert-base-chinese, num_labels7, id2labelid2label, label2idlabel2id, )id2label、label2id这两个参数是可选的但它们会让保存后的模型自带可读标签名部署时不用再额外维护一份映射。加载本地权重时把路径换成模型目录即可tokenizer要从同一个目录加载否则词表不一致会带来一系列诡异问题具体现象放在避坑章。4.2 构建Dataset把文本和标签封装成模型输入有了预处理函数数据层就很简单。Dataset只需要返回input_ids、attention_mask和labels三个张量import torch from torch.utils.data import Dataset class NerDataset(Dataset): def __init__(self, texts, labels_list, tokenizer, label2id, max_len): self.texts texts self.labels_list labels_list self.tokenizer tokenizer self.label2id label2id self.max_len max_len def __len__(self): return len(self.texts) def __getitem__(self, idx): text self.texts[idx] char_labels self.labels_list[idx] input_ids, attention_mask, label_ids make_input( text, char_labels, self.tokenizer, self.label2id, self.max_len ) return { input_ids: torch.tensor(input_ids, dtypetorch.long), attention_mask: torch.tensor(attention_mask, dtypetorch.long), labels: torch.tensor(label_ids, dtypetorch.long), }训练时不需要自己处理特殊token位置的lossBertForTokenClassification内部会拿labels里的-100作为ignore_index只对真实token计算交叉熵。这也是为什么预处理时要把-100放对位置。如果放错了loss会统计到padding位置上指标看起来还行实际预测结果是乱的。常见检查方式单独取一条样本打印input_ids、label_ids和原始text人工对一遍长度。4.3 训练循环与关键超参数照抄能跑但要懂怎么改from torch.utils.data import DataLoader dataloader DataLoader( NerDataset(train_texts, train_labels, tokenizer, label2id, max_len128), batch_size32, shuffleTrue, ) optimizer AdamW(model.parameters(), lr2e-5) model.train() for epoch in range(3): for step, batch in enumerate(dataloader): input_ids batch[input_ids].cuda() attention_mask batch[attention_mask].cuda() labels batch[labels].cuda() outputs model(input_ids, attention_maskattention_mask, labelslabels) loss outputs.loss optimizer.zero_grad() loss.backward() optimizer.step() if step % 50 0: print(fepoch {epoch} step {step} loss {loss.item():.4f})这段循环是训练的核心几个超参数直接影响结果。学习率2e-5是BERT微调的保守起点5e-5往往更快但风险更大见过不少人在1e-5和4e-5之间找到最优。batch_size受显存限制序列长度128时32是常见取值显存小就降到16。epoch一般2到4轮超过5轮几乎必过拟合。还有一个常被忽略的细节BERT微调建议加warmup前10%的训练步数里学习率从0线性升到目标值能避免训练初期大梯度把预训练权重冲坏。参数常见取值影响学习率2e-5 ~ 5e-5太大会破坏预训练权重太小收敛慢batch_size16 / 32显存与梯度稳定性max_len64 / 128 / 256覆盖长实体与显存开销epochs2 ~ 4超过5容易过拟合warmup_ratio0.1稳定前期梯度评估时记得切model.eval()并包torch.no_grad()否则dropout在推理阶段仍然工作结果忽高忽低。这个现象很迷惑人训练集和验证集F1都正常一到线上预测就掉点十有八九是忘了切换模式。模型输出之后要把logits转成实体序列model.eval() with torch.no_grad(): outputs model(input_ids, attention_maskattention_mask) logits outputs.logits pred_ids logits.argmax(dim-1).squeeze().tolist() pred_labels [id2label[i] for i in pred_ids if i ! -100]argmax逐token解码会忽略标签之间的转移约束比如“O后面直接接I-PER”这种非法序列可能被argmax带出来需要用规则后处理或者CRF层约束。这就是下一章要展开的边界问题第一版模型可以先不管。5. 中文NER避坑指南5个让我反复翻车的真实场景中文NER的坑不少大部分不是BERT本身的问题而是出在数据处理和训练策略上。下面五条按我踩过的频率排序每一条按现象、原因、解决三个层次写。这些坑不一定立刻让程序报错但它们会让效果卡在一个上不去的水平而且排查起来很耗时间。5.1 标签错位tokenizer把多个字合并成了一个token现象训练时loss迟迟不降或者验证集上预测结果整体偏移一位。某个实体的尾部被识别成下一个实体边界错得很有规律。原因bert-base-chinese不是按字来切分的词表里包含常见词“某大学”可能直接成为一个token。如果代码里直接拿char_labels的长度去拼input_ids两个序列长度根本不一致标签就被整体推偏。解决用offset_mapping按token覆盖的字符区间取标签特殊token给-100也就是第3章那套函数。这条是整个流程里新手踩得最多的一步而且经常发生在“代码没报错”的情况下。5.2 长文本截断实体会被拦腰斩断现象验证集里短文本F1有90长文本的机构名、地址类实体几乎全错错误集中在句子后半部分。原因默认truncationTrue是保留前128个token句子后半段直接被丢掉实体后半部分缺失导致边界错误。解决不要让模型直接处理长文本。先按句号、逗号切句子再按每句跑切出来的片段如果涉及跨句实体要带上前后约50字的上下文避免边界处被拆散。max_len也不要盲目调大256以上收益递减显存开销却线性上涨。先分析语料长度分布再定别拍脑袋。5.3 输出几乎全是O损失在多数类上“躺平”现象训练正常loss持续下降但推理结果几乎没有实体B标签很少出现。原因实体占比太低BERT线性头的初始输出偏向O梯度被大量O样本主导。解决先确认训练集里真的包含实体样本别用错文件再考虑给O类降权。简单做法是在loss里给实体类乘一个权重或者对包含实体的句子做过采样。还有一个容易忽略的点如果你用了类别权重要同步调整验证阶段的判定阈值否则实体类被过度惩罚F1还是上不去。5.4 低频实体类型怎么训都上不去整体F1还忽上忽下现象PER、ORG都到90以上唯独某类实体只有60不到而且每次训练结果波动很大。原因样本太少几百条训练数据里某类实体只有二三十个模型学不到稳定模式。解决分实体类型做评估先确认短板在哪一类。对低频类型把含这类实体的句子复制几份做过采样也可以想办法让同一个batch里尽量覆盖各类型样本。不要只盯着整体F1它会被高频类型掩盖。低频类型的F1方差本来就大连续跑三次取平均值再比较别被单次运气左右结论。5.5 领域权重替换后效果反而变差现象换了一个号称“领域中文BERT”的权重F1没升反降甚至加载时报tokenizer不匹配。原因很多领域权重只是在通用权重基础上继续预训练过但词表和分词器被人为改过还有的权重在做下游任务时已经发生过过拟合拿它继续微调反而把旧任务偏差带了进来。解决替换前先在固定测试集上跑同一套脚本做对比加载时tokenizer必须和权重配套。如果只是为了领域词汇覆盖优先考虑下一章说的MLM二次预训练而不是直接找现成的微调权重。6. 中文NER再进一步CRF约束、领域二次预训练与分类型验证6.1 当线性分类头不够用在BERT输出后加CRF层BertForTokenClassification对每个token独立分类没有显式建模标签转移比如B-PER后面直接跳I-ORG这种不合法的转移不会被惩罚。CRF层在这个位置加一个转移矩阵用维特比在解码时找全局最优序列。什么时候值得加数据量有几千条以上、边界错误明显的项目加几百条的小数据先别加CRF的转移矩阵在稀缺标签上更容易学偏。核心结构很清晰class BertCrfNer(nn.Module): def __init__(self, bert_model, num_labels): super().__init__() self.bert bert_model self.classifier nn.Linear(bert_model.config.hidden_size, num_labels) self.crf CrfLayer(num_labels) # 自行实现的转移矩阵层 def forward(self, input_ids, attention_mask, labelsNone): seq_out self.bert(input_ids, attention_maskattention_mask)[0] logits self.classifier(seq_out) if labels is not None: return -self.crf(logits, labels, maskattention_mask.bool()) return self.crf.decode(logits, maskattention_mask.bool())CrfLayer内部需要维护一个num_labels乘num_labels的转移分数表训练时用序列级别的对数似然解码时用维特比。加了CRF之后如果发现loss整体变高不用慌因为计算目标从逐token交叉熵换成了序列级别的负对数似然数值范围本来就不同要看的是验证集F1有没有涨。CRF对实体的边界精细化有效但它不会解决低频实体样本不足的问题方向别搞混。6.2 领域适配用无标注语料做MLM继续预训练如果业务文本是病历、法律文书这种领域性很强的语料通用BERT对领域词汇的区分可能不够。常见做法是拿大量无标注领域文本让BERT继续做掩码语言模型训练几千步后再换到NER微调。这个阶段不需要任何实体标注数据获取成本低性价比很高。from transformers import BertForMaskedLM mlm_model BertForMaskedLM.from_pretrained(bert-base-chinese) # 用领域纯文本训练学习率建议 2e-5 # 跑 20005000 步后保存再用这份权重初始化 NER 模型注意MLM继续预训练的数据量一般至少十万句以上才开始有稳定收益只有几千句领域文本时收益可能还抵不上对比测试的方差。另一个经验是不要在NER微调后的模型上再做MLM。微调过的权重已经偏向当前任务再去做MLM会把NER层的分布冲掉两头都捞不到。6.3 验证技巧按实体类型拆开看F1别被整体指标骗了最后分享一个从低频实体翻车里学到的习惯。当时整体F1很好看进到业务现场才发现某类实体完全不可用因为它在整体指标里占比太小。现在我的验证脚本固定按实体类型输出F1并单独统计每个类型的样本量。具体做法是在评估循环里按B标签的实体类型累计TP、FP、FN最后分别计算precision、recall、F1。这样一眼就能看出哪类实体欠拟合也决定了下一步要过采样、加CRF还是做领域适配。我现在接到新的中文NER需求顺序固定是先统计实体分布、跑一个BERT基线、分类型看指标再决定要不要继续堆模块。这套流程不一定是最快的但能少走很多弯路。希望帮到你。本文还有配套的精品资源点击获取