简介:面向Python开发与自然语言处理学习者,这份资源演示如何基于Hugging Face Transformers加载预训练BERT模型完成中文命名实体识别(NER),覆盖人名、地名、组织名等实体类型的识别与标注。压缩包共9个文件、约3.72MB,包含Python模型脚本、训练/验证/测试文本数据集、评估脚本、可视化示例图及说明文档,可支撑从数据预处理到模型训练、评估的完整流程。内容涉及BERT的MLM与NSP预训练机制、IOB标注体系、AdamW优化器及学习率调度等核心知识点,并针对中文分词与字符编码的特殊性给出了处理说明。通过完整代码与数据,读者可自行复现实验、调整参数,结合Precision、Recall和F1指标验证模型效果。已有3342人学习下载,资源结构清晰,可直接参考运行,适合课程设计、毕业设计或实际项目入门中文NER任务。
1. 中文NER不是调包:BERT在这里到底做了什么
中文命名实体识别(NER)是我平时接触最多的落地任务之一。预训练语言模型BERT做中文NER,核心不是“加载一个模型跑一遍预测”,而是要把中文分词、标签体系、序列标注对齐、长文本边界处理这一连串问题一次想清楚。很多团队拿到一份标注好的中文语料就开始微调,最后卡在“训练loss不降”或者“预测结果全是O标签”上,十有八九不是模型不行,而是数据还没变成BERT能消化的形态。这份资源整理的是一整套可以直接复现的流程:从BIO标签设计、数据预处理、微调训练到推理对齐和蒸馏部署,每一步都可以照着跑、按着改。适合有Python基础、知道基本深度学习概念、想快速把中文NER落到实际业务里的工程师。
2. 选基座、定标签、预处理:把中文句子变成BERT认识的张量
2.1 中文BERT基座怎么选:字模型与词模型的真正分界
中文预训练模型和英文不太一样,最大的分界在“输入是字还是词”。英文BERT走的是WordPiece子词切分,一个单词会被拆成几个子词;中文BERT普遍走字级切分,也就是说输入层一个token基本对应一个汉字。这一差异直接决定了后续标签对齐的写法。
常见的基座有两大派别:通用中文BERT(一般叫chinese-bert-base)和全词掩码版本(一般叫chinese-roberta-wwm-ext)。前者训练早、生态成熟;后者在全词掩码策略上做了增强,对中文实体边界的建模更细,尤其在个人名、地名、机构名这类边界敏感的实体上,体感提升明显。如果标注语料是金融、医疗、法律这类强领域文本,优先考虑在对应领域语料上继续预训练的基座,而不是直接拿通用模型硬跑。领域模型虽然词表更大、运行更慢,但小样本下的实体召回率通常能高出一截。
另一个容易忽视的点是词表大小。中文BERT的词表一般在两万左右,对生僻字、异体字、特殊符号覆盖不足。标注语料里频繁出现生僻字时,先检查tokenizer是否把它们切成了[UNK]。如果[UNK]比例超过1%,就要重新考虑词表适配,或者补充一个自定义词表,否则这些字符对应的标签永远学不好。常见做法是在数据预处理阶段统计词表覆盖度,把出现频率最高的生僻字加入自定义词表,但这会涉及修改tokenizer的vocab文件和模型embedding层,工程成本不小,值得在项目开始前评估。
选基座还有一个经验:不要只看名称,要实际跑一次。下载权重后,拿一条包含目标实体的样本走一遍tokenizer.tokenize,肉眼检查切分结果。这一步能提前发现[UNK]、子词拆分、特殊token追加位置等问题,省掉后面大量排查时间。
2.2 标签体系:BIO还是BIOES,以及标签id映射
中文NER的标签体系,业界用得最久的是BIO,后来BIOES也在不少竞赛方案里出现。两者的差别在于边界描述粒度和预测约束方式。
BIO使用三种状态:B表示实体开始,I表示实体内部,O表示非实体。配合实体类型,比如B-PER、I-PER、B-ORG、I-ORG。解析的时候,遇到一个B-XXX开启新实体,随后连续的I-XXX并入该实体,遇到O或者其他B则结束当前实体。BIO的优点是标签种类少,类别不平衡问题稍微轻一点;缺点是缺少实体结束标记,依赖后续序列模型去隐式学习边界。
BIOES在BIO基础上增加E(实体结束)和S(单字实体),对于单字实体和实体末尾的识别更直接,CRF这类结构化解码器能更显式地利用边界约束。我一般建议:如果标注语料不大、实体类型少于10种,直接上BIOES;如果只想快速跑通流程,BIO足够,后续再切换成BIOES只需要改标签映射和解析函数。
标签id的映射表是整个流程的地基,强烈建议用配置文件或者python字典统一管理,不要在手写代码里散落硬编码。常见的映射写法如下:
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()} # 额外增加一个特殊标签,用于填充和忽略 PAD_LABEL_ID = -100这里的PAD_LABEL_ID是关键。训练时要让padding位置的标签不参与损失计算,PyTorch的CrossEntropyLoss天然支持ignore_index=-100,因此所有padding位置都填-100,而不是填0,否则模型会被迫学习“空字符是O标签”,导致纯中文文本上的实体边界被污染。
2.3 数据预处理:一条工单样本如何变成模型输入
中文NER的标注数据通常有两种格式:一种是按字标注,每行一个字加一个标签,空行分隔样本;另一种是JSON格式,每个样本包含原始文本和实体span列表。无论哪种,都要在预处理阶段统一转成BERT的输入格式。
以最经典的按字标注为例,先写一个加载函数:
def load_conll_format(file_path): samples = [] chars, tags = [], [] with open(file_path, encoding="utf-8") as f: for line in f: line = line.strip() if line == "": if chars: samples.append((chars, tags)) chars, tags = [], [] continue parts = line.split("\t") chars.append(parts[0]) tags.append(parts[1]) if chars: samples.append((chars, tags)) return samples这段逻辑没什么玄学,就是按空行切样本,但要留意两个细节:第一,文件末尾如果缺少空行,最后一个样本会丢失,所以函数末尾要补一次if chars的判定。第二,中文标注文件经常混入全角空格和换行符,读入后要用strip()去掉首尾空白,否则一个全角空格会被当成一个汉字样本。这类数据瑕疵我在真实项目里碰到过多次,预处理脚本里多一行防御性代码,后续少踩很多坑。
下一步是把字符序列和标签序列转成BERT的输入。这里必须按token对齐,不能想当然地认为“一个汉字等于一个token”:
from transformers import AutoTokenizer tokenizer = AutoTokenizer.from_pretrained("chinese-bert-base") max_len = 128 def encode_sample(chars, tags): input_ids = [tokenizer.cls_token_id] label_ids = [PAD_LABEL_ID] # CLS位置不参与实体预测 for char, tag in zip(chars, tags): sub_tokens = tokenizer.tokenize(char) if not sub_tokens: sub_tokens = [tokenizer.unk_token] sub_ids = tokenizer.convert_tokens_to_ids(sub_tokens) input_ids.extend(sub_ids) # 一个原始字可能拆成多个子词,标签要跟着重复 label_ids.extend([label2id[tag]] * len(sub_ids)) input_ids = input_ids[: max_len - 1] label_ids = label_ids[: max_len - 1] # 补齐到固定长度 padding_len = max_len - len(input_ids) input_ids += [tokenizer.pad_token_id] * padding_len label_ids += [PAD_LABEL_ID] * padding_len # 末尾补SEP input_ids[-1] = tokenizer.sep_token_id return { "input_ids": input_ids, "attention_mask": [1 if id_ != tokenizer.pad_token_id else 0 for id_ in input_ids], "label_ids": label_ids, }这段代码是网上所有中文NER教程的公共骨架,但很多版本省略了子词重复这一步。中文BERT虽然以字切分为主,但有相当一部分符号和罕见字会被拆成多个子词,比如“妳”“𬇕”这类字符。标签扩展顺序必须和token顺序严格一致,一旦错位,模型等于在乱标数据上训练。
另一个参数上的细节是max_len的选择。BERT标准结构支持最长512个token,但中文样本里512个字对很多业务场景根本不够。不要急着把max_len设到512,先统计语料的长度分布,选择覆盖90%以上样本的最小值,通常128或256足够。长度设得越长,训练和推理越慢,显存占用成倍上涨。
3. 微调训练闭环:Linear头还是CRF,学习率怎么设
3.1 接Linear还是接CRF:从两块损失说起
BERT本体输出每个token的上下文向量,NER任务要在这一层之上加一个分类结构。最常见的两种做法是:直接接一个Linear+Softmax的分类头,或者接一个Linear+CRF的解码层。两者的损失函数完全不同,效果差异也比很多人想象的大。
Linear头把NER当成逐token的多分类问题。每个token独立判断自己属于哪个标签,没有显式的标签转移约束,模型只能靠BERT的上下文隐式学习“B后面才能跟I”这类规则。它的实现简单、训练快、调试方便,是绝大多数团队先跑的baseline。
CRF头在Linear输出之上建模标签序列的联合概率。它有一张可学习的转移矩阵,显式约束标签间的合法转移,比如“O后面不能直接接I-PER”。在实体边界重叠、标签种类多的场景下,CRF能让最终序列更符合标注规范,实体级F1通常能提升1到3个点。代价是解码用维特比算法,训练和推理都变慢,且实现复杂度上升。
我的建议是分两步走:第一步用Linear头把数据和流程跑通,确认loss能正常下降、预测不是清一色O;第二步再上CRF或者直接用带CRF的封装实现。不要一上来就CRF,否则你很难分辨“效果差是CRF的问题还是数据预处理的问题”。如果你用的是transformers生态,可以直接用AutoModelForTokenClassification,它包装的是Linear头,CRF需要额外引入第三方实现或者自己手写维特比解码。
3.2 训练参数:lr、epoch、warmup与batch size怎么设
中文NER微调的参数设置有一些通用经验值,但也要根据语料规模和实体分布调整。下面这组参数是我在多个中文NER任务里验证过的起点:
| 参数 | 常见取值范围 | 说明 |
|---|---|---|
| 主干学习率 | 2e-5 到 5e-5 | 预训练权重更新幅度要小 |
| 分类头学习率 | 5e-5 到 1e-4 | 随机初始化的头可以学得更快 |
| batch size | 16 到 32 | 取决于GPU显存和句长 |
| epoch | 3 到 5 | 小数据量下过拟合风险明显 |
| warmup比例 | 0.1 | 前10%步数线性预热 |
| max_len | 128到256 | 覆盖90%语料长度即可 |
batch size的选择有个容易被忽略的地方:真正决定显存占用的是“batch size × 序列长度”的乘积。同样16的batch,128长度和512长度显存差好几倍。显存不够时,优先减小max_len而不是batch size,因为batch太小会让梯度估计噪声很大,训练不稳定。
epoch的设置要结合验证集早停。3到5个epoch只是起点,实际训练时每轮保存checkpoint,验证集实体F1不再提升就回退到上一轮。预训练模型微调时,epoch过大极易导致灾难性遗忘,模型在训练集上表现很好,但对新样本泛化能力直线下降。
3.3 训练代码:从transformers加载到自定义训练循环
虽然可以套用Trainer一条龙训练,但很多场景下自定义训练循环反而更清晰,尤其是要按层设置不同学习率时。先加载模型和优化器:
from transformers import AutoModelForTokenClassification, AdamW, get_linear_schedule_with_warmup model = AutoModelForTokenClassification.from_pretrained( "chinese-bert-base", num_labels=len(label2id) ) # 分类头学习率可以大一点,主干保持小学习率 optimizer = AdamW([ {"params": model.bert.parameters(), "lr": 2e-5}, {"params": model.classifier.parameters(), "lr": 5e-5}, ]) total_steps = len(train_dataloader) * epochs scheduler = get_linear_schedule_with_warmup( optimizer, num_warmup_steps=int(total_steps * 0.1), num_training_steps=total_steps )这里model.bert.parameters()是主干预训练参数,model.classifier.parameters()是随机初始化的分类头。如果用的是AutoModelForTokenClassification,分类头字段名通常叫classifier;如果是自定义结构,字段名可能不同,需要打印模型结构确认。学习率分层是个微调技巧,对中文NER的效果影响很大:主干学习率过大,预训练知识会被快速冲掉;分类头学习率太小,新任务学不动。
训练循环本身不长,但有几个关键点要留意:
import torch model.train() for epoch in range(epochs): for step, batch in enumerate(train_dataloader): input_ids = batch["input_ids"].to(device) attention_mask = batch["attention_mask"].to(device) labels = batch["label_ids"].to(device) outputs = model( input_ids=input_ids, attention_mask=attention_mask, labels=labels # 传入真实标签作为训练目标 ) loss = outputs.loss loss.backward() # 梯度裁剪防止loss爆炸 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step() scheduler.step() optimizer.zero_grad() if step % 100 == 0: print(f"epoch {epoch} step {step} loss {loss.item():.4f}")打印loss时不要只看数值绝对值,关注它的下降趋势。BERT微调初期loss通常下降很快,如果某一步突然跳高又恢复,多半是batch里混入了异常样本。另外,梯度裁剪max_norm=1.0是常用默认值,在loss开始震荡时可以调到0.5观察。labels传入-100的位置会自动被CrossEntropyLoss忽略,这也是padding标签填-100的原因。
关于model(**batch)内部发生了什么,简单说:模型内部根据labels是否传入自动决定返回loss还是仅返回logits。推理阶段不传labels,只拿outputs.logits做预测。不要为了图省事在推理时也传全0的假label,那会让模型多计算一次损失,浪费算力还可能污染统计结果。
4. 推理与边界对齐:从token预测还原成中文实体
4.1 offset mapping:把token位置映回原始文本
训练是标签对齐的问题,推理则是位置还原的问题。模型输出的是每个token的标签,但使用者需要的是实体在原文中的起止位置。这里必须依赖tokenizer提供的offset_mapping字段。
from transformers import AutoTokenizer tokenizer = AutoTokenizer.from_pretrained("chinese-bert-base") text = "张三在腾云科技担任算法工程师" inputs = tokenizer( text, return_offsets_mapping=True, truncation=True, max_length=128, return_tensors="pt" ) offset_mapping = inputs["offset_mapping"].squeeze(0).tolist() # offset_mapping中的每个元素是 (start, end),表示该token对应原字符串的字符区间offset_mapping会返回和input_ids等长的列表,每个元素是(start, end)字符位置元组。特殊token([CLS]、[SEP]、[PAD])对应的offset是None,在遍历时要跳过。这一点在实际代码里非常容易踩坑,因为直接用下标组装实体span时会发现起始位置是None,随后报类型错误。
得到预测标签后再配合offset映射,就能还原出实体文本。下面是一段完整的推理与实体抽取代码:
import torch id2label = {0: "O", 1: "B-PER", 2: "I-PER", 3: "B-ORG", 4: "I-ORG", 5: "B-LOC", 6: "I-LOC"} def predict_entities(text, model, tokenizer, max_len=128): inputs = tokenizer( text, return_offsets_mapping=True, truncation=True, max_length=max_len, return_tensors="pt" ) offset_mapping = inputs["offset_mapping"].squeeze(0).tolist() with torch.no_grad(): logits = model( input_ids=inputs["input_ids"].to(device), attention_mask=inputs["attention_mask"].to(device) ).logits[0] pred_ids = logits.argmax(dim=-1).cpu().tolist() entities = [] current_type = None start = end = None for offset, tag_id in zip(offset_mapping, pred_ids): if offset is None: # 跳过 [CLS]、[SEP]、[PAD] 等特殊token continue tag = id2label[tag_id] if tag == "O": if current_type is not None: entities.append({"type": current_type, "start": start, "end": end}) current_type = None elif tag.startswith("B-"): if current_type is not None: entities.append({"type": current_type, "start": start, "end": end}) current_type = tag[2:] start, end = offset elif tag.startswith("I-"): if current_type == tag[2:]: end = offset[1] else: # I标签没有前导B,视为噪声,丢弃或跳过 if current_type is not None: entities.append({"type": current_type, "start": start, "end": end}) current_type = None if current_type is not None: entities.append({"type": current_type, "start": start, "end": end}) # 转回实体的实际文本 results = [] for ent in entities: results.append({ "type": ent["type"], "text": text[ent["start"]:ent["end"]], "start": ent["start"], "end": ent["end"] }) return results这段代码的核心逻辑是状态机式的BIO解析。遍历每个token的预测标签和offset:遇到B-时开启新实体并记录起始位置;遇到同类型的I-时不断更新结束位置;遇到O或不同类型的标签时关闭当前实体。最后用text[start:end]直接切出实体文本,因为offset里的end是Python切片语义的尾部(不包含),所以不需要再加1。
4.2 长文本切段与重叠窗口恢复
BERT的上限是512个token,而现实业务里大量文本超过这个长度。直接把超长文本截断,会导致尾部实体全部丢失;不做处理硬塞进模型,会报长度错误。常见做法是滑窗切段,分段预测后拼接实体列表。
def split_long_text(text, max_len=256, stride=64): windows = [] start = 0 while start < len(text): end = min(start + max_len, len(text)) windows.append(text[start:end]) if end == len(text): break start = end - stride return windows参数max_len=256是每个窗口最多处理的字符数,stride=64是相邻窗口的重叠字符数。滑窗的核心思想是:实体的边界可能落在窗口边缘,重叠区能保证一个完整实体至少在一个窗口内完整出现。需要注意几点:窗口末尾可能在实体中间,这会导致该窗口预测出半截实体;重叠策略下会出现同一实体被相邻两个窗口同时识别,所以需要对跨窗口实体做去重。常见做法是只保留每个窗口中间部分的预测,左右两侧各去掉一段,只取[stride/2, max_len - stride/2]范围内的标签,这样能有效减少边界重复实体。我在实际项目中一般取max_len=384, stride=128,在实体召回率和推理耗时之间比较平衡。如果文本中实体非常密集,stride可以再拉大。
4.3 实体后处理:合并BIO与过滤空标签
模型输出的原始预测会存在不合法序列,比如连续出现B-PER和B-ORG而中间没有O,或者出现孤立的I-PER没有前导B-PER。这些脏数据如果不处理,直接喂给下游系统会造成实体错乱。
一个普适的后处理规则是:遇到B标签强制阻断当前实体;遇到I标签时检查当前是否有同类型B标签在开放,没有则丢弃这个I或把它当新实体起点。上面的predict_entities代码里已经写了这个逻辑,但要注意tag[2:]取实体类型在单字符实体类型时容易出错,更稳妥的方式是用正则或者直接比较字符串。
另外一个容易漏的处理是空字符串实体。有的预测会把长度为0的span记录为实体,多发生在特殊token的offset映射异常时。后处理函数最后要做一次过滤,把所有start == end或者text == ""的实体直接丢掉。这部分不需要什么高级算法,一段简单的列表推导式就够了。
results = [e for e in results if e["start"] != e["end"] and e["text"].strip()]5. 避坑:五个翻车案例,每条都有原因与修法
5.1 标签错位:一条样本输出的标签数量与字数量对不上
现象:训练开始时loss数值异常大,或者模型预测结果的标签序列长度和perplexity对不上,排查数据集发现第N个样本的label_ids长度比input_ids短几个token。
原因:绝大多数是因为tokenizer把个别汉字拆成了多个子词,而标签没有跟着重复。直接按处理器对原始字符和标签做zip配对,遇到拆词后标签数量就对不上了。这类问题在中文生僻字、全角标点、emoji字符上尤其明显,普通汉字测试集看不出来。
解决:预处理统一改成“先tokenizer.tokenize(char),再根据子词个数len(sub_tokens)重复标签”。这段代码在2.3节已经给出,实际使用时把zip逻辑整体替换成repeat逻辑即可。我还会在预处理后加一条断言,检查len(input_ids) == len(label_ids),不相等直接抛异常。
5.2 实体被截断在512边界
现象:同一个实体在长文本中有时能被识别,有时被切掉一半,尤其是靠近512边界位置的实体经常丢失或残缺。
原因:模型输入有长度上限,超过512的文本被截断后,处于截断点附近的实体自然被切掉。如果截断点恰好落在实体中间,模型只看得到半个实体,推理端解析BIO时只能输出半截实体。
解决:对超长文本采用重叠滑窗推理,并且在拼接预测结果时只保留每个窗口中间部分,边缘部分丢弃。这样边界实体的损失被控制在最小范围。滑窗的窗口大小和重叠步长要按业务文本的长度分布来定,不要直接抄一个512加64的方案。
5.3 全部预测为O或者几乎全是O
现象:模型训练完,验证时实体预测几乎全是O标签,偶尔出几个B标签却没有对应的I标签,实际实体一个都抽不出来。
原因:最常见的情况是类别严重不平衡。NER数据集里O标签占比通常超过80%,模型只要学会全输出O就能拿到很低的loss,于是收敛到一个“偷懒”的解。另一个常见原因是学习率设得太大,预训练参数被冲掉,模型表达能力退化。
解决:第一,loss里给O标签设置较低的权重,或者给正实体标签设置较高权重。第二,训练时对O标签做下采样,降低O样本比例。第三,检查学习率,主干降到2e-5以下再观察loss。如果做完这三步还是全O,就要回去看预处理,检查标签是否全部被意外重置成O或者padding标签覆盖了真实标签。
5.4 多次训练结果不稳定,实体F1忽高忽低
现象:同一份数据、同一份代码,连续训练两次,验证集F1相差好几个点。
原因:模型初始化有两个随机来源:分类头是随机初始化的,训练过程有dropout随机性,没有固定随机种子时,这些因素会导致结果不稳定。BERT主干是预训练权重,影响较小,但分类头的随机初始化在数据量偏少时影响被放大。
解决:训练脚本开头固定所有随机源,包括PyTorch、Python random和NumPy:
import random import numpy as np import torch def set_seed(seed): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) set_seed(42)注意固定种子之后不要频繁切换设备和batch order,训练数据loader也建议固定shuffle seed,否则结果仍然有差异。
5.5 推理速度太慢,CPU上识别一条文本要好几秒
现象:模型在GPU上表现很好,但部署到CPU或低配服务器后,单条短文本推理耗时超过2秒,生产环境根本扛不住。
原因:BERT本体的参数量在1亿级别,前向计算复杂度高;推理时如果忘了加torch.no_grad(),还会额外保存计算图,显存和CPU时间都被浪费;再加上单条样本逐个预测,没有按batch合并,放大了开销。
解决:推理阶段必须包torch.no_grad();多条样本合并成一个batch一起走,能显著提高GPU利用率;如果CPU推理是硬性需求,只能做模型压缩。先说清楚,只是量化(比如用int8)在中文NER这种密集分类任务上效果有限,更有效的是蒸馏,安排一个轻量模型从BERT的预测结果中学习,具体做法在下面一章展开。
6. 蒸馏到轻量模型:保持NER效果的部署加速方案
一个完整的NER服务,最终逃不过部署环节。BERT在GPU上推理一条短文本大约几十毫秒,但在CPU或边缘设备上会变成秒级。对于高并发、低延迟的中文NER场景,一个实用路线是用知识蒸馏把BERT的能力迁移到轻量模型上。具体做法是:让教师BERT在训练集上产出软标签概率分布,学生模型同时学习真实标签和教师的概率分布。
distill_loss = nn.KLDivLoss(reduction="batchmean") ce_loss = nn.CrossEntropyLoss(ignore_index=-100) T = 4 # 温度参数,控制概率分布的平滑程度 alpha = 0.5 # 真实标签与教师信号的权重比例 teacher_logits = teacher_model(input_ids=input_ids, attention_mask=attention_mask).logits.detach() student_logits = student_model(input_ids=input_ids, attention_mask=attention_mask).logits loss = alpha * ce_loss(student_logits.view(-1, num_labels), labels.view(-1)) \ + (1 - alpha) * distill_loss( F.log_softmax(student_logits / T, dim=-1), F.softmax(teacher_logits / T, dim=-1) ) * T * T温度系数T一般取2到8。温度越高,教师输出的概率分布越平滑,学生能学到类别间的隐性关联;温度太低,软标签退化成近似硬标签,蒸馏退化成普通训练。alpha控制蒸馏损失和真实标签损失的比重,0.5起步比较稳妥,数据量小的时候可以适度提高教师信号的权重。需要额外说明的是,上述脚本适合教师模型和student模型一起前向计算的情况;如果教师模型太大无法同时载入,可以先跑一遍教师模型把所有样本的logits缓存到磁盘,再训练学生。
学生模型的选择不一定是另一个预训练语言模型,也可以换成结构更轻的BiLSTM-CRF或者中小规模的ALBERT变体。蒸馏训练完成后,用同一套评估脚本对比学生和教师的实体级F1,目标是学生比教师低3个点以内属于可接受的部署模型。NER的评测指标要优先看实体级F1,而不是逐token准确率。逐token准确率会把O标签的比例算进去,O占大头时准确率虚高,哪怕实体全没抽出来也能拿到95%以上的token准确率。实体级F1要求实体类型和边界都完全匹配才算对,是中文NER更硬核的验收标准。
有一回我上线一个客服工单实体抽取服务,拿着token级准确率97%的评测报告高兴了一下午,结果拉出预测样本逐条检查,发现预定义的12类实体只抽出来5类,剩下的要么边界偏移、要么类型张冠李戴。从那以后,每次做中文NER评估我都强制先跑一遍实体级F1,再单独检查每一类实体的precision和recall,确认没有哪一类被整体漏掉。实体边界差一个字,就算模型再强,下游系统也没法用。这只是众多坑里的一个,处理中文NER,数据对齐、边界还原和评测口径这三关每一个都值得多花时间打磨。希望帮到你。
本文还有配套的精品资源,点击获取