简介:面向自然语言处理初学者及具备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-表示实体片段的起始 token,I-表示实体片段的内部 token,O表示不属于任何实体。一个类别在标签表里占两个位置,加上O,总数就是2n+1。笔记里给了 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-*再拼O,O会跑到第 10 位,模型输出层的第 0 类就变成了B-address。这在单次训练里不影响收敛,但会导致两个问题:一是别人拿你的 checkpoint 推理时必须知道你的枚举顺序,否则 argmax 出来的 id 全错位;二是迁移学习时如果复用旧模型的分类头,形状对得上、语义对不上,非常隐蔽。
常见做法是把id2label和label2id一起写进模型目录下的config.json,BertForTokenClassification.from_pretrained加载时会自动读回来。这相当于给标签表买了一份后悔药——以后不管谁接手,都不用去翻训练脚本猜顺序。
下面是完整的 21 个 id 对照,跑完上面那段代码可以直接核对:
| id | label | 语义 | 典型触发片段 |
|---|---|---|---|
| 0 | O | 非实体 | 绝大多数汉字 |
| 1 | B-address | 地址起始 | 「某市」「某区」开头 |
| 2 | B-book | 书籍起始 | 书名号内首字 |
| 3 | B-company | 公司起始 | 机构名首字 |
| 4 | B-game | 游戏起始 | 作品名首字 |
| 5 | B-government | 政府机构起始 | 部门名首字 |
| 6 | B-movie | 影视起始 | 片名首字 |
| 7 | B-name | 人名起始 | 姓氏首字 |
| 8 | B-organization | 组织起始 | 团体名首字 |
| 9 | B-position | 职位起始 | 职务名首字 |
| 10 | B-scene | 场景起始 | 地点名首字 |
| 11–20 | I-address … I-scene | 对应实体的内部 token | 承接 B- 标签 |
用表格而不是一段文字列出来,是因为对齐调试时你会反复回来查 id。比如日志里打印出「第 17 个 token 预测为 2」,你要能在两秒内反应过来那其实是B-book而不是某个I-标签。
2.2 换成 BIOES 还是加辅助类别:什么情况下值得动标签表
BIO 之外还有 BIOES(增加E-结束、S-单字实体)和 BMES 一类变体。理论上 BIOES 能表达实体边界,对边界判定有微弱帮助,代价是标签数从2n+1涨到4n+1,本案例就是 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 都输出O,token 级准确率就有 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里现在有四个 key:input_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 内最大长度,而不是统一截到 512:
import 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, dtype=torch.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, dtype=torch.long)])) # 关键:padding 位置标签设 -100,交给 CrossEntropyLoss 跳过 out['labels'].append(torch.cat([x['labels'], torch.full((pad,), -100, dtype=torch.long)])) return {k: torch.stack(v) for k, v in out.items()}collate_fn里能改的参数只有两个:padding 值(input_ids用 0 即[PAD]id,attention_mask用 0,token_type_ids用 0)和labels的填充值(必须-100)。这里不要用tokenizer.pad一把梭,因为tokenizer.pad不会帮你处理labels,一旦labels用了 0 填充,模型就真的在学「把填充区预测成 O」,评估时这部分还会混进 F1 计算。
另外要注意截断策略。中文 NER 里长句很多,直接truncation=True到 512 会静默丢掉尾部实体,标注文件里明明有、模型永远学不到。我的习惯是先按句号分句、让单条样本尽量不超过 400 字,再对超长的做滑动窗口切分,切的时候在窗口重叠区重新对齐标签。
4.2 学习率、batch size 与冻结策略
微调 BERT 的 NER 头,超参数的敏感度排序大致是:学习率 > batch size > epoch 数 > warmup 比例。
学习率是最大的坑。从头预训练 BERT 的 lr 在 1e-4 量级,但微调时 BERT 主干已经是很不错的中文表示,太大反而会把预训练学到的语义破坏掉。常见区间是 2e-5 到 5e-5,BertForTokenClassification的分类头是随机初始化的,可以给头单独配 10 倍 lr。用AdamW,weight_decay0.01,warmup_ratio0.1。
import torch from transformers import BertForTokenClassification, get_linear_schedule_with_warmup model = BertForTokenClassification.from_pretrained( 'bert-base-chinese', num_labels=len(label_names), id2label=id2label, label2id=label2id, ) # 主干与分类头分两组学习率 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_decay=0.01) total_steps = len(train_loader) * 5 # 5 个 epoch scheduler = get_linear_schedule_with_warmup(optimizer, num_warmup_steps=int(total_steps * 0.1), num_training_steps=total_steps)num_labels=21必须显式传,不能依赖 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(average='macro')算 token 级 F1,分数会虚高到 0.9 以上,因为O占绝大多数。真正有意义的是实体级 F1:一个实体的所有 token 都预测对了才算对,边界错一位就算错。
seqeval就是干这个的,输入是标签字符串的嵌套列表而不是 id:
from 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, digits=4)这段代码里有两个细节值得停一下。一是过滤条件是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.0000,classification_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()就对不上;再比如原文用了全角「2024」,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.json(model.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_len=512): """返回 [(实体文本, 实体类型, 起始字符下标, 结束字符下标), ...]""" enc = tokenizer(text, return_offsets_mapping=True, truncation=True, max_length=max_len, return_tensors='pt') 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_mapping=True是关键参数,它让 tokenizer 返回每个 token 在原始字符串里的字符区间;[CLS]、[SEP]、[PAD]这些不对应原文的 token,区间是(0, 0),用start == end就能一刀切掉,不用再单独判断特殊符号。这比用convert_ids_to_tokens再比字符串干净得多,顺便也绕开了「subword 带##找不到原文位置」的问题。
合并逻辑里有两条规则容易被忽略。第一条是I-标签必须校验类型和前面B-一致,B-name后面跟一个I-company在语法上是非法的,很多模型会输出这种组合,直接当新实体处理比硬合并更安全。第二条是遇到O或非法I-时必须立刻收掉当前实体,否则会把两个本不相邻的实体粘成一个长串。
truncation=True和max_len=512意味着超长文本尾部会被切掉,实体可能被截断。生产环境常见的做法是先按标点滑窗切分,每段 400 字左右并留 50 字重叠区,逐段推理后按字符区间去重合并。去重时如果两个实体区间有重叠且类型相同,取并集;类型不同则保留得分更高的那个,得分可以从softmax后的概率里取被选中标签的概率值。
验证推理结果是否正确,我一般用反向校验:把extract_entities输出的实体按区间在原文里标出来,再和标注文件里的实体列表逐条比。差一条都要查,因为推理阶段的错和训练阶段的错来源不同——前者多半是后处理合并逻辑的问题,后者才是对齐和标签表的问题。这一步做完,一份能用的中文 NER 服务才算闭环。
从那以后我每次接新的 NER 数据,都强制先把「标签表打印一遍 → 挑三条含英文数字的句子跑对齐 → 单 batch 过拟合 8 条样本」这三步走完再开训,图省事跳过哪一步,后面都要用双倍时间还回来。希望帮到你。
本文还有配套的精品资源,点击获取