中文NER微调避坑:BIO标签与subword对齐全流程 简介面向自然语言处理初学者及具备Python基础的算法开发者这份PDF文档聚焦中文命名实体识别任务系统梳理BERT预训练模型微调的理论背景、实体类型定义、BIO标签编码、文本分词与模型评估等核心环节帮助读者建立从预训练模型到序列标注任务的完整认知。文档共1个PDF文件压缩包约545KB篇幅紧凑适合作为课程设计、项目入门或技术查阅的轻量资料。目前已有1245人学习。内容覆盖10类实体及id2label、label2id完整映射结合BertTokenizer说明input_ids、token_type_ids与attention_mask的作用并讨论分词后标签对齐、分类层构建以及准确率、召回率和F1值评估等实践要点可作为NER微调流程的参考模板与排错线索。1. 一份 17 页微调笔记的落点中文 NER 卡在标签这一环自然语言处理里命名实体识别是最容易被低估的一类任务模型结构不复杂BertForTokenClassification一行代码就能加载但真正决定成败的是标签体系和对齐逻辑。手上这份笔记把 BERT 预训练模型微调做 NER 的全流程拆成了四段——实体定义、label 编号、中文分词、字符级 BIO 到 subword 的映射每一段都配了能直接跑的代码和真实打印输出而不是只贴结论。它适合已经能用 transformers 跑通文本分类、第一次转做序列标注的人也适合做过 NER 但被[UNK]、##子词和-100坑过、想回头把对齐细节看明白的人。下面按能复现的顺序重新走一遍重点放在原文里那些只能靠注释才看得懂的地方。2. 标签表怎么定21 个 label 的枚举顺序与 B-/I- 语义序列标注的起点不是模型是标签表。标签表错了后面训练得再顺评估出来的 F1 也是废的。这一步看着简单实际上有三个决定要拍用不用 BIO、类别怎么排、id 从几开始。这三件事一旦定下来就写进了模型最后一层的权重形状里中途改要重训。2.1 十类实体扩成 21 个 label 的推导过程BIO 是序列标注里最常见的编码方式B-表示实体片段的起始 tokenI-表示实体片段的内部 tokenO表示不属于任何实体。一个类别在标签表里占两个位置加上O总数就是2n1。笔记里给了 10 类实体所以标签表正好 21 个。# 实体类别资料里给出的 10 类 entities [address, book, company, game, government, movie, name, organization, position, scene] # BIO 三段拼接O 在最前B-* 居中I-* 在最后 label_names [O] [B- x for x in entities] [I- x for x in entities] # 双向映射id2label 用于推理时还原label2id 用于把标注文件转成 id id2label {i: label for i, label in enumerate(label_names)} label2id {label: i for i, label in id2label.items()} print(len(label_names)) # 21 print(label2id[O], label2id[B-address], label2id[I-scene]) # 0 1 20这段代码本身只有四行但顺序是有讲究的。[O]必须放在最前面让O固定拿到 id 0如果先拼B-*再拼OO会跑到第 10 位模型输出层的第 0 类就变成了B-address。这在单次训练里不影响收敛但会导致两个问题一是别人拿你的 checkpoint 推理时必须知道你的枚举顺序否则 argmax 出来的 id 全错位二是迁移学习时如果复用旧模型的分类头形状对得上、语义对不上非常隐蔽。常见做法是把id2label和label2id一起写进模型目录下的config.jsonBertForTokenClassification.from_pretrained加载时会自动读回来。这相当于给标签表买了一份后悔药——以后不管谁接手都不用去翻训练脚本猜顺序。下面是完整的 21 个 id 对照跑完上面那段代码可以直接核对idlabel语义典型触发片段0O非实体绝大多数汉字1B-address地址起始「某市」「某区」开头2B-book书籍起始书名号内首字3B-company公司起始机构名首字4B-game游戏起始作品名首字5B-government政府机构起始部门名首字6B-movie影视起始片名首字7B-name人名起始姓氏首字8B-organization组织起始团体名首字9B-position职位起始职务名首字10B-scene场景起始地点名首字11–20I-address … I-scene对应实体的内部 token承接 B- 标签用表格而不是一段文字列出来是因为对齐调试时你会反复回来查 id。比如日志里打印出「第 17 个 token 预测为 2」你要能在两秒内反应过来那其实是B-book而不是某个I-标签。2.2 换成 BIOES 还是加辅助类别什么情况下值得动标签表BIO 之外还有 BIOES增加E-结束、S-单字实体和 BMES 一类变体。理论上 BIOES 能表达实体边界对边界判定有微弱帮助代价是标签数从2n1涨到4n1本案例就是 41 个。类别数翻倍直接带来的问题是每个类别的样本量本来就少再拆成四份长尾类别比如game、scene可能一个 batch 里都凑不齐几个正样本训练信号被稀释。我一般的判断标准是单类实体样本少于 500 条时不要上 BIOES先把主力放在数据清洗和对齐正确性上单类实体超过 5000 条、且边界错误在 badcase 分析里占三成以上再考虑换编码。这份资料用的就是最朴素的 BIO对入门和中等规模数据集是合适的。另一个常见的变体是加一个[PAD]专用 label把它编到 21。这其实没必要因为 PyTorch 的CrossEntropyLoss自带ignore_index参数把 padding 位置的标签设成-100就会跳过不需要占用一个真实类别。多编一个类别的坏处是模型可能真的把某个 token 预测成[PAD]类后处理还得额外过滤。注意-100是ignore_index的默认值但只在CrossEntropyLoss/NLLLoss里生效。如果你自己手写 loss 或者换成 focal loss记得显式传ignore_index-100否则 padding 位置会正常参与梯度模型会花大量参数去学「怎么把填充区预测成 O」。2.3 类别不平衡的量化O 占了百分之多少才算严重一份普通中文语料的 NER 标注里O标签占比通常在 85% 到 95% 之间。这个数字意味着如果模型对所有 token 都输出Otoken 级准确率就有 90% 左右。所以用准确率做监控指标是自欺欺人必须换成实体级 F1。标签分布要算出来看不抽样不知道严重程度from collections import Counter # all_labels 是训练集里所有 labels 列表拼接后的扁平序列 cnt Counter(all_labels) total sum(cnt.values()) for label_id, c in cnt.most_common(5): print(f{id2label[label_id]:16}{c:8}{c / total:.2%})如果O的占比超过 95%而你的实体类别又有小众类建议在 loss 上加weight参数权重按类别频次的倒数开平方来配开平方是为了不让极稀有类别的权重爆炸导致模型对这几个类过拟合。如果O占 90% 出头直接不加权、靠 F1 选最优 epoch 也够用。这份资料里没提类别权重因为它处理的是相对均衡的十类语料换到自己的数据上这一步不能省。3. 中文分词与标签对齐把字符级 BIO 映射到 subword标签表定完下一个真正容易翻车的地方是分词。标注文件是字符级的一个字一个标签BertTokenizer输出的 token 序列长度和字符数不相等多出来的[CLS]、[SEP]、拆开的英文子词、被合并的[UNK]全都要分配标签。这一步做错训练照样能收敛指标也照样能看但模型学到的是错位的对应关系。3.1 BertTokenizer 输出的三个张量各自的角色先看最基础的一次编码from transformers import BertTokenizer tokenizer BertTokenizer.from_pretrained(bert-base-chinese) text 《爱丽丝梦游仙境》的原著小说 enc tokenizer(text) print(enc[input_ids]) print(tokenizer.convert_ids_to_tokens(enc[input_ids]))输出里能直接观察到的规律序列首尾固定是 101 和 102对应[CLS]和[SEP]中间的汉字基本做到一字一 token英文和数字会被拆成 subword后续的片段带##前缀。三个张量的作用要分清楚不然调 bug 时容易抓错方向input_ids是真正喂进 embedding 层的整数序列长度等于 token 数特殊符号各占一位。token_type_ids用来区分句子对任务里的第一句和第二句NER 是单句任务全部为 0。有人会把它从 Dataset 里去掉其实可以BertModel在缺省时会自动补零但显式传更稳妥尤其是换模型的时候。attention_mask标记哪些位置是真实 token全 1 表示没有 padding。只有在 batch 内做 padding 或截断到max_length之后尾部才会出现 0。单条样本编码时attention_mask恒为 1这一点在调试对齐时很好用——如果打印出来发现 mask 里已经有 0说明你的输入被 padding 过那么 token 与 label 的下标已经偏移对齐函数不能在这上面跑。提示BertTokenizer处理中文时会出现「全角标点被单独切成一个 token」「生僻字落到[UNK]」两种情况。前者不影响对齐因为长度仍是 1后者会让对齐函数里tok char的断言失效必须单独分支处理这也是原文那段函数里[UNK]要拿当前字符标签而不是O的原因。3.2 get_token_label 的分支逻辑逐段拆对齐函数是整个流程里最容易写错、也最值得逐行看的地方。核心思路只有一句同时推进 token 迭代器和字符迭代器按 token 的类型分三种情况处理。def get_token_label(text, char_label, tokenizer): 把字符级 BIO 标签映射到 tokenizer 输出序列上长度必须与 tokens 一致 enc tokenizer(text) tokens tokenizer.convert_ids_to_tokens(enc[input_ids]) token_labels [] char_iter iter(zip(text.lower(), char_label)) cur_char, cur_label next(char_iter) for tok in tokens: # 分支一特殊 token。[CLS]/[SEP] 一律 O[UNK] 继承当前字符标签 if tok in tokenizer.special_tokens_map.values(): if tok [UNK]: token_labels.append(cur_label) try: cur_char, cur_label next(char_iter) except StopIteration: pass else: token_labels.append(O) continue # 分支二单字符 token。汉字、标点走这里直接断言后对齐 if len(tok) 1: assert tok.lower() cur_char, f{tok} ! {cur_char} token_labels.append(cur_label) try: cur_char, cur_label next(char_iter) except StopIteration: pass continue # 分支三多字符 token。英文词、数字串、带 ## 的 subword 走这里 clean tok.replace(##, ).lower() merged None for ch in clean: # 跳过 tokenizer 不认识的字符全角符号、罕见字等 while cur_char ! ch and cur_char not in tokenizer.vocab: try: cur_char, cur_label next(char_iter) except StopIteration: break if merged ! O and cur_label ! O: merged cur_label try: cur_char, cur_label next(char_iter) except StopIteration: pass token_labels.append(merged if merged else O) assert len(token_labels) len(tokens) return token_labels三个分支各自的意图值得说清楚。分支一处理特殊符号。[CLS]和[SEP]是人为插入的不对应原文任何字符所以给O而且不能消耗字符迭代器——一旦消耗了后面所有字符都会错位一格这是最典型的一类assert失败。[UNK]特殊它对应的是原文某个真实字符只是 tokenizer 的词典里没有收录所以它的标签必须继承那个字符的标签同时也要消耗一个字符。如果偷懒给O那些含生僻字的实体就永远学不出来。分支二处理单字符。汉字和标点绝大多数落在这里assert tok.lower() cur_char保证两边同步。注意这里加了.lower()因为分词器内部可能对某些英文字母做了小写化而标注文件里的文本未必统一。分支三处理多字符 token。这类 token 在中文语料里占比不高但一旦出错就是灾难clean去掉##之后逐字符消费原文merged取这一串字符里第一个非O的标签。这个合并规则是「就高不就低」——只要这一串里有一个字符属于实体整个 subword 就算实体的一部分。对英文专有名词来说这是合理的因为 subword 本身就是同一个词被拆开边界不应该出现在词内部。assert len(token_labels) len(tokens)是最后一道保险。这条断言不过说明上面某个分支多消耗或少消耗了一个 token/字符后面的训练全部白做。3.3 对齐结果的自检手段三行代码就能省两小时函数写完不要直接进训练先肉眼验一遍。最简单的方式就是原文里那句for t, l in zip(tokens, token_labels): print(...)但我建议在打印之外再加上下面这组检查出问题能立刻定位# 1. 长度一致性函数内部已断言这里再验一次更放心 assert len(tokens) len(token_labels) # 2. 特殊 token 位置必须是 O assert token_labels[0] O and token_labels[-1] O # 3. 统计对齐后各类别数量和原文标注的实体数量比对 from collections import Counter aligned Counter(token_labels) print(aligned.most_common(5))第二项检查能抓到一类隐蔽 bug如果[CLS]被错给了某个B-标签模型会学到「句首必是实体」推理时每条输入都会在第一个位置吐出一个假实体。第三项检查是为了和原始标注文件对账——如果原标注里「人名」有 120 个B-name对齐后统计出来只有 100 个说明有 20 个在 subword 合并或者[UNK]处理时被吃掉了这时候要回头查是哪类字符触发的问题。我一般还会专门挑几条含英文、含数字、含书名号的句子单独跑这三类是对齐最容易炸的输入。中文纯文本跑通不代表混排跑得通血泪经验。4. 训练集构造与微调配置从 sample 到 BertForTokenClassification对齐函数本身不带数据它要被包进样本构造函数再被包进 Dataset最后进 DataLoader。这一层是把前面所有准备工作变成可训练张量的地方。4.1 make_sample 与 Dataset/collate_fn 的衔接单条样本的构造很轻def make_sample(text, label, tokenizer): sample tokenizer(text) token_label get_token_label(text, label, tokenizer) sample[labels] [label2id[x] for x in token_label] return samplesample里现在有四个 keyinput_ids、token_type_ids、attention_mask、labels长度天然相等因为get_token_label的返回值是基于convert_ids_to_tokens的结果生成的。这一点很关键——如果直接用原文text的长度当labels长度会漏掉特殊 token报错信息通常是Expected input batch_size (32) to match target batch_size (30)。进 Dataset 之前要解决变长问题。BERT 的输入要求 batch 内等长常见做法是动态 padding 到 batch 内最大长度而不是统一截到 512import torch from torch.utils.data import Dataset class NERDataset(Dataset): def __init__(self, samples): self.samples samples def __len__(self): return len(self.samples) def __getitem__(self, idx): s self.samples[idx] return {k: torch.tensor(v, dtypetorch.long) for k, v in s.items()} def collate_fn(batch): max_len max(x[input_ids].size(0) for x in batch) keys [input_ids, token_type_ids, attention_mask] out {k: [] for k in keys [labels]} for x in batch: pad max_len - x[input_ids].size(0) for k in keys: out[k].append(torch.cat([x[k], torch.zeros(pad, dtypetorch.long)])) # 关键padding 位置标签设 -100交给 CrossEntropyLoss 跳过 out[labels].append(torch.cat([x[labels], torch.full((pad,), -100, dtypetorch.long)])) return {k: torch.stack(v) for k, v in out.items()}collate_fn里能改的参数只有两个padding 值input_ids用 0 即[PAD]idattention_mask用 0token_type_ids用 0和labels的填充值必须-100。这里不要用tokenizer.pad一把梭因为tokenizer.pad不会帮你处理labels一旦labels用了 0 填充模型就真的在学「把填充区预测成 O」评估时这部分还会混进 F1 计算。另外要注意截断策略。中文 NER 里长句很多直接truncationTrue到 512 会静默丢掉尾部实体标注文件里明明有、模型永远学不到。我的习惯是先按句号分句、让单条样本尽量不超过 400 字再对超长的做滑动窗口切分切的时候在窗口重叠区重新对齐标签。4.2 学习率、batch size 与冻结策略微调 BERT 的 NER 头超参数的敏感度排序大致是学习率 batch size epoch 数 warmup 比例。学习率是最大的坑。从头预训练 BERT 的 lr 在 1e-4 量级但微调时 BERT 主干已经是很不错的中文表示太大反而会把预训练学到的语义破坏掉。常见区间是 2e-5 到 5e-5BertForTokenClassification的分类头是随机初始化的可以给头单独配 10 倍 lr。用AdamWweight_decay0.01warmup_ratio0.1。import torch from transformers import BertForTokenClassification, get_linear_schedule_with_warmup model BertForTokenClassification.from_pretrained( bert-base-chinese, num_labelslen(label_names), id2labelid2label, label2idlabel2id, ) # 主干与分类头分两组学习率 head_params [p for n, p in model.named_parameters() if classifier in n] backbone_params [p for n, p in model.named_parameters() if classifier not in n] optimizer torch.optim.AdamW([ {params: backbone_params, lr: 2e-5}, {params: head_params, lr: 2e-4}, ], weight_decay0.01) total_steps len(train_loader) * 5 # 5 个 epoch scheduler get_linear_schedule_with_warmup(optimizer, num_warmup_stepsint(total_steps * 0.1), num_training_stepstotal_steps)num_labels21必须显式传不能依赖 config 里读出来的默认值——bert-base-chinese原始 config 里没有num_labels不传的话分类头会按 2 类建训练第一轮就形状报错。batch size 取决于显存。中文 NER 一般 8 到 32 都能跑如果显存吃紧优先加gradient_accumulation_steps而不是硬砍 batch size因为小 batch 会让 batch 内统计量波动大BN 层虽然 BERT 没有但 LayerNorm 也会受影响。冻结策略上数据量小于 2000 条时建议先冻结底层 6 层只训上层数据量大了再全量微调——这个判断标准没有普适性跑两组对比看验证集 F1 是最直接的。4.3 用 seqeval 算实体级 F1 而不是 token 准确率评估是这一整套里最容易被偷工减料的环节。如果直接用sklearn的f1_score(averagemacro)算 token 级 F1分数会虚高到 0.9 以上因为O占绝大多数。真正有意义的是实体级 F1一个实体的所有 token 都预测对了才算对边界错一位就算错。seqeval就是干这个的输入是标签字符串的嵌套列表而不是 idfrom seqeval.metrics import classification_report, f1_score def evaluate(model, loader, device): model.eval() all_preds, all_labels [], [] with torch.no_grad(): for batch in loader: batch {k: v.to(device) for k, v in batch.items()} logits model(**batch).logits preds torch.argmax(logits, dim-1).cpu().numpy() golds batch[labels].cpu().numpy() for p, g in zip(preds, golds): # 只保留真实 token 位置padding 与特殊 token 一并剔除 p [id2label[i] for i, gi in zip(p, g) if gi ! -100] g [id2label[i] for i in g if i ! -100] all_preds.append(p) all_labels.append(g) return classification_report(all_labels, all_preds, digits4)这段代码里有两个细节值得停一下。一是过滤条件是gi ! -100而不是gi ! 0——0是O的真实 id用它过滤会把所有非实体位置一起删掉评估就没意义了。二是model(**batch)里batch已经包含labels函数内部会自动算 loss如果不想要 loss 就先把labelspop 出来。classification_report的输出按类别给出 precision/recall/F1最有价值的观察点是support一列如果某个类 support 只有个位数它的 F1 波动会非常大不要拿来判断模型好坏去看 macro 平均或者干脆合并类别。注意训练时的 loss 用CrossEntropyLoss默认就带ignore_index-100但BertForTokenClassification内部计算 loss 时也用的是这个默认值。如果你把labels里的 padding 改成了别的数字模型内部不会报错只是会在那些位置硬算 loss损失曲线会奇怪地平稳但 F1 上不去。5. 避坑排查标签对齐这条路上最容易翻车的五处这一节列的都是我实际在 NER 微调里遇到过、并且身边人反复撞上的问题。每条按「现象 → 原因 → 解决」写能对号入座就直接省掉半天。坑一训练 loss 稳步下降验证集 F1 一直卡在 0现象第一个 epoch loss 从 2.9 掉到 0.3看着很健康但seqeval的 F1 始终是 0.0000classification_report里除O外所有类别都是 0。原因模型学会了全部预测O。这在O占比 95% 以上的数据里是最省力的解loss 确实能降得很低。诱因通常有两个类别权重没配或者标签 id 顺序错乱导致模型实际在学一个错位的目标。解决先看classification_report的O类 recall 是不是 1.0是的话基本可以确认。然后检查label2id和id2label是否一致{v: k for k, v in ...}一个字符写反就会出这种问题再对稀有类别加weight。如果加权重后 F1 还是 0用单条样本过拟合测试——拿 8 条样本训 200 步正常的话 loss 应该趋近 0 且 F1 逼近 1过拟合不过去就说明数据管线有硬 bug。坑二RuntimeError: expected sequence of length 43 at dim 1 (got 41)现象训练跑得到第一个 batch 就崩报错信息里两个长度差 2 或差几。原因labels的长度和input_ids不一致。差 2 的典型情况是labels按原文字符数生成没算上[CLS]和[SEP]差更多的情况是英文或数字被拆成了多个 subword而标签还按字符数走。解决在make_sample里加一句assert len(sample[labels]) len(sample[input_ids])让错误在数据构造阶段就暴露而不是等到模型 forward。同时确认get_token_label里那条末尾断言没被注释掉——有人调试时图省事注释了忘了恢复。坑三模型对每条输入都在句首吐一个实体现象推理结果里每条文本开头都多出一个实体类别还挺固定通常是数据里最常见的那个类。或者句子结尾位置总有一个长度为 1 的假实体。原因[CLS]或[SEP]位置被分到了非O标签。对齐函数里分支一写反了或者分支二先执行、把特殊 token 当普通单字符处理就会出这个问题。解决训练前加断言token_labels[0] O and token_labels[-1] O全量数据跑一遍。推理阶段再加一层后处理把特殊 token 对应位置的预测强制置为O双保险。坑四含英文或数字的句子对齐断言必炸现象纯中文句子跑得好好的一旦出现「2024 年」「AI 模型」这种混排就抛AssertionError或者对齐出来的标签整体错位一格。原因多字符 token 分支里逐字符消费原文时大小写、全角半角、##前缀三件事没有统一处理。比如原文是「AI」而 tokenizer 输出ai的两个 token没有.lower()就对不上再比如原文用了全角「」tokenizer 归一化成半角字符迭代器会一直卡住不前进。解决对齐前先把text和分词结果都做一次lower()加全半角归一化用unicodedata.normalize(NFKC, text)统一。多字符分支里那个while cur_char ! ch and cur_char not in tokenizer.vocab的循环条件也要保留让迭代器能跳过 tokenizer 不认识的字符而不是死循环。坑五训练时 F1 0.88部署后掉到 0.6现象本地验证集指标很好看换一批新数据或者上线服务后效果断崖式下跌。原因大概率是推理时的label2id顺序和训练时不一致。模型输出的是 logits 下标还原成标签靠的是id2label如果部署环境里重新构造了一遍 label 表、顺序变了模型预测 id 3 本来该是B-company会被解释成别的类。另一种可能是推理时漏了return_offsets_mapping把 token 位置当成了原文字符位置实体偏移全错。解决训练结束把id2label显式写进config.jsonmodel.config.id2label id2label后save_pretrained推理端从 config 里读绝不自己重新拼。推理时用offset_mapping把 token 下标映射回原文字符区间这一步是必做的。6. 推理落地把 logits 还原成实体片段并做后处理模型训完最后的活是把一堆 21 维 logits 变成「哪几个字、是什么类」的结构化结果。这一段经常被简化成argmax加打印但真正上线时缺了后处理会很难用。import numpy as np import torch from transformers import BertTokenizer, BertForTokenClassification def extract_entities(text, model, tokenizer, id2label, max_len512): 返回 [(实体文本, 实体类型, 起始字符下标, 结束字符下标), ...] enc tokenizer(text, return_offsets_mappingTrue, truncationTrue, max_lengthmax_len, return_tensorspt) offsets enc.pop(offset_mapping)[0].tolist() model.eval() with torch.no_grad(): logits model(**enc).logits[0] pred_ids torch.argmax(logits, dim-1).tolist() entities, cur [], None for (start, end), pid in zip(offsets, pred_ids): if start end: # 特殊 token 的 offset 是 (0, 0)直接跳过 continue label id2label[pid] if label.startswith(B-): if cur: entities.append(cur) cur {type: label[2:], start: start, end: end} elif label.startswith(I-) and cur and label[2:] cur[type]: cur[end] end # 同类实体延续延长右边界 else: if cur: entities.append(cur) cur None if cur: entities.append(cur) return [(text[e[start]:e[end]], e[type], e[start], e[end]) for e in entities]逐段说清楚。return_offsets_mappingTrue是关键参数它让 tokenizer 返回每个 token 在原始字符串里的字符区间[CLS]、[SEP]、[PAD]这些不对应原文的 token区间是(0, 0)用start end就能一刀切掉不用再单独判断特殊符号。这比用convert_ids_to_tokens再比字符串干净得多顺便也绕开了「subword 带##找不到原文位置」的问题。合并逻辑里有两条规则容易被忽略。第一条是I-标签必须校验类型和前面B-一致B-name后面跟一个I-company在语法上是非法的很多模型会输出这种组合直接当新实体处理比硬合并更安全。第二条是遇到O或非法I-时必须立刻收掉当前实体否则会把两个本不相邻的实体粘成一个长串。truncationTrue和max_len512意味着超长文本尾部会被切掉实体可能被截断。生产环境常见的做法是先按标点滑窗切分每段 400 字左右并留 50 字重叠区逐段推理后按字符区间去重合并。去重时如果两个实体区间有重叠且类型相同取并集类型不同则保留得分更高的那个得分可以从softmax后的概率里取被选中标签的概率值。验证推理结果是否正确我一般用反向校验把extract_entities输出的实体按区间在原文里标出来再和标注文件里的实体列表逐条比。差一条都要查因为推理阶段的错和训练阶段的错来源不同——前者多半是后处理合并逻辑的问题后者才是对齐和标签表的问题。这一步做完一份能用的中文 NER 服务才算闭环。从那以后我每次接新的 NER 数据都强制先把「标签表打印一遍 → 挑三条含英文数字的句子跑对齐 → 单 batch 过拟合 8 条样本」这三步走完再开训图省事跳过哪一步后面都要用双倍时间还回来。希望帮到你。本文还有配套的精品资源点击获取