1. 从统一视角重新理解命名实体识别
命名实体识别(Named Entity Recognition,简称NER)是自然语言处理里最基础也最容易被低估的任务之一。说它基础,是因为几乎所有的信息抽取、知识图谱构建、智能问答、搜索排序系统,底层都依赖NER把文本里的关键片段识别出来;说它容易被低估,是因为很多人以为NER就是“把句子里的实体词标出来”,真正上手之后才发现,嵌套实体、不连续实体、少样本场景、跨领域迁移,每一个都是硬骨头。
传统做法把NER拆成两个割裂的阶段:先做实体边界检测,再对每个实体做类型分类。这种“先找span再分类”的流水线思路直观,但问题也很明显——边界检测阶段不知道类型信息,分类阶段又依赖边界检测的结果,误差会逐级累积。更麻烦的是,当遇到嵌套实体(一个实体内部还包含另一个实体)或者不连续实体(一个实体被其他词打断)时,这种两阶段架构几乎无法优雅处理。
ACL2020上有一篇工作提出了一个很有意思的思路:把NER统一建模成机器阅读理解(Machine Reading Comprehension,简称MRC)问题。核心想法是——与其让模型去“标注序列”,不如让模型去“回答问题”。给定一段文本,针对每一种实体类型构造一个自然语言问句,比如“文本中的人物是谁?”“文本中的地点在哪里?”,然后让模型像做阅读理解一样,从文本中抽取出对应的答案片段。这样一来,边界检测和类型分类就被统一到了一个问答框架里,不再割裂。
这个思路之所以值得单独拿出来讲,是因为它不仅仅是一个模型结构的改动,而是对NER任务范式的一次重新定义。它把“序列标注”变成了“片段抽取”,把“多分类”变成了“多问句并行”,把“边界+类型”两个独立问题合并成了一个端到端的span预测问题。对于做信息抽取的从业者来说,理解这个统一框架,比单纯记住某个模型的F1分数更有价值。
这篇文章我会从设计思路、核心细节、实操落地、问题排查几个角度,把这个统一MRC框架拆开讲透。不管你是刚接触NER的新手,还是已经用过BiLSTM-CRF、BERT-CRF的老手,都能从中拿到可以直接复用的经验。
2. 统一MRC框架的整体设计与思路拆解
2.1 为什么要把NER变成阅读理解
先说清楚一个前提:NER的本质是什么?给定一个token序列,找出其中属于预定义类型集合的连续片段。传统序列标注的做法是给每个token打一个标签,比如B-PER、I-PER、O,然后通过CRF或者softmax解码出实体片段。这个做法在扁平实体上工作得很好,但一旦实体类型变多、实体结构变复杂,标签体系就会膨胀,模型需要同时学习“边界在哪里”和“类型是什么”两件事。
MRC框架的切入点很巧妙:它把“类型是什么”这个问题,通过问句的方式显式地告诉模型。比如对于人物类型,构造问句“文本中的人物是谁?”;对于地点类型,构造问句“文本中的地点在哪里?”。模型拿到问句和原文之后,只需要做一件事——在原文中找到答案span的起始和结束位置。类型信息已经编码在问句里了,模型不需要再去区分“这个span是人物还是地点”,因为不同问句对应不同答案。
这个设计的好处是显而易见的。第一,边界检测和类型分类被统一成了一个span抽取问题,不再有误差累积。第二,问句本身携带了先验知识,模型可以利用问句的语义来辅助定位实体,这在少样本场景下尤其有用。第三,框架天然支持嵌套实体和不连续实体——因为每个问句独立抽取答案,不同问句的答案可以重叠,嵌套和不连续就不再是特殊case,而是框架的自然能力。
提示:把NER变成MRC,本质上是用“问句”作为类型标签的载体。问句设计得好不好,直接决定模型能不能学到正确的类型语义。
2.2 问句构造的几种策略与取舍
问句构造是这个框架里最需要动脑筋的地方。问句太简单,模型学不到类型语义;问句太复杂,又可能引入噪声。常见的构造策略有三种。
第一种是位置无关的模板问句,比如“文本中的人物是谁?”“文本中的地点在哪里?”。这种问句不依赖具体上下文,通用性强,适合类型体系固定的场景。缺点是问句之间区分度可能不够,比如“人物”和“组织”的问句在语义上比较接近,模型容易混淆。
第二种是带标注示例的问句,比如“文本中的人物是谁?例如:张三、李四。”。这种问句通过示例给模型提供额外的类型线索,在少样本场景下效果提升明显。但示例的选择需要谨慎,如果示例和当前文本领域差异太大,反而会误导模型。
第三种是基于关键词的问句,比如“文本中与‘人物’相关的实体是什么?”。这种问句把类型名称直接嵌入问句,简单直接,但语义丰富度不如前两种。
实际落地时,我一般建议先用第一种模板问句跑通baseline,然后根据验证集表现决定是否加入示例。如果类型之间混淆严重,可以尝试在问句里加入更细粒度的描述,比如“文本中的人名、昵称、代称是什么?”来增强区分度。
2.3 统一框架相比传统序列标注的优势边界
统一MRC框架并不是万能的,它有自己明确的优势边界。在扁平实体、类型体系固定、标注数据充足的场景下,传统BERT-CRF依然是非常强的baseline,甚至在某些数据集上还能略胜一筹。MRC框架的真正优势体现在三个场景。
第一个场景是嵌套实体。传统序列标注很难处理嵌套,因为一个token只能有一个标签。MRC框架下,不同问句独立抽取答案,外层实体和内层实体可以同时被不同问句抽出来,天然支持嵌套。
第二个场景是少样本和零样本。问句本身携带了类型语义,模型可以利用预训练阶段学到的语言知识来理解问句,从而在标注数据很少的情况下也能有不错的表现。这一点在跨领域迁移时特别有价值。
第三个场景是类型体系动态变化。传统序列标注一旦类型集合变了,标签体系就要重新设计,模型也要重新训练。MRC框架下,新增一个类型只需要新增一个问句,框架结构不变,扩展成本低很多。
注意:如果你的场景是纯扁平实体、类型固定、数据充足,不要盲目上MRC框架。先用BERT-CRF跑baseline,确认瓶颈确实在嵌套或类型扩展上,再考虑切换。
3. 核心细节解析与实操要点
3.1 问句设计与类型语义的对齐
问句设计的核心目标是让模型能够从问句中准确理解“我要找的是什么类型的实体”。这里有一个容易被忽略的细节:问句的语义空间和原文的语义空间需要在同一个向量空间里对齐。也就是说,模型不仅要理解原文,还要理解问句,并且把两者匹配起来。
实操中,我通常会把问句和原文拼接成一个序列输入模型,格式类似“[CLS] 问句 [SEP] 原文 [SEP]”。模型通过self-attention让问句和原文交互,最终在原文部分预测答案span的起始和结束位置。这种拼接方式的好处是问句和原文在同一个注意力机制下交互,模型可以动态地根据问句调整对原文的关注。
问句的措辞需要和类型名称保持语义一致。比如类型是“人物”,问句用“文本中的人物是谁?”就比“文本中的实体是什么?”更有效,因为前者和类型名称的语义距离更近。如果类型名称本身比较抽象,比如“其他”,问句可以写成“文本中不属于上述类型的实体是什么?”,通过排除法来定义语义。
3.2 Span抽取的起始结束位置预测机制
MRC框架下,答案span的抽取通常采用两个分类器:一个预测起始位置,一个预测结束位置。具体来说,模型对原文的每个token输出两个概率分布,分别表示该token作为答案起始和结束的概率。训练时,起始位置和结束位置的交叉熵损失相加作为总损失。
这里有一个实操细节:起始和结束位置是独立预测的,推理时需要保证起始位置小于等于结束位置。常见做法是枚举所有合法的(start, end)组合,选择起始概率和结束概率乘积最大的组合作为最终答案。如果某个问句没有答案,模型需要能够输出“无答案”,这通常通过一个特殊的[CLS]位置或者一个额外的无答案分类器来实现。
提示:起始和结束位置独立预测会带来一个潜在问题——模型可能预测出起始位置在结束位置之后的非法span。推理时一定要加约束,否则会得到很多无效答案。
3.3 多问句并行的计算开销与优化
每种实体类型对应一个问句,如果有N种类型,就需要N次前向推理。如果类型很多,计算开销会线性增长。实操中可以通过两种方式优化。
第一种是共享原文编码。问句和原文拼接后,原文部分的编码其实在不同问句之间是重复计算的。可以把原文先编码一次,然后每个问句只编码问句部分,通过cross-attention让问句和原文交互。这样原文只编码一次,计算开销大幅降低。
第二种是问句批量并行。把所有问句和原文的拼接序列组成一个batch,一次前向推理处理所有问句。这样虽然单次推理的序列长度变长了,但GPU利用率更高,总体吞吐量反而更好。
实际落地时,我一般先用批量并行跑通,如果显存不够再考虑共享原文编码的方案。两种方案的效果差异通常不大,主要看工程实现的便利性。
4. 实操过程与核心环节实现
4.1 数据准备与问句模板生成
假设我们有一个标准的NER数据集,格式是每行一个token加一个标签,句子之间用空行分隔。第一步是把这种序列标注格式转换成MRC格式。
转换逻辑是这样的:对于每个句子,遍历所有实体类型,为每个类型生成一个问句,然后从句子中找出该类型对应的所有实体span作为答案。如果一个类型在句子中没有对应实体,答案就是“无答案”。
# 伪代码示例:序列标注转MRC格式 def convert_to_mrc(tokens, labels, type_to_question): mrc_samples = [] for entity_type, question in type_to_question.items(): answers = [] start = None for i, label in enumerate(labels): if label == f"B-{entity_type}": start = i elif label == f"I-{entity_type}" and start is not None: continue elif label == f"O" or label.startswith("B-"): if start is not None: answers.append((start, i - 1)) start = None if start is not None: answers.append((start, len(tokens) - 1)) mrc_samples.append({ "question": question, "context": tokens, "answers": answers if answers else [(-1, -1)] # -1表示无答案 }) return mrc_samples这段代码的核心是把BIO标签序列转换成span列表。注意处理实体在句尾结束的情况,以及连续实体之间的边界。转换完成后,每个原始句子会变成N个MRC样本,N等于实体类型数量。
4.2 模型输入构造与标签对齐
MRC样本构造好之后,需要把问句和原文拼接成模型输入。以BERT为例,输入格式是“[CLS] 问句 [SEP] 原文 [SEP]”。标签是原文部分每个token的起始和结束位置标记。
# 伪代码示例:构造模型输入和标签 def build_input(question, context, answers, tokenizer, max_len=512): q_ids = tokenizer.encode(question, add_special_tokens=False) c_ids = tokenizer.encode(context, add_special_tokens=False) input_ids = [tokenizer.cls_token_id] + q_ids + [tokenizer.sep_token_id] + c_ids + [tokenizer.sep_token_id] # 原文部分的起始位置 context_start = len(q_ids) + 2 start_labels = [0] * len(input_ids) end_labels = [0] * len(input_ids) for start, end in answers: if start == -1: continue start_labels[context_start + start] = 1 end_labels[context_start + end] = 1 return input_ids, start_labels, end_labels这里有一个关键细节:原文经过tokenizer之后,token数量可能和原始token数量不一致(比如中文分词和BERT tokenizer的差异)。实操中需要维护一个原始token到BERT token的映射,确保标签对齐正确。如果映射关系复杂,可以考虑用字符级别的标注,或者直接用BERT tokenizer重新分词。
4.3 训练配置与超参数选择
训练配置方面,我一般用BERT-base作为backbone,学习率2e-5到3e-5,batch size根据显存调整,通常16到32。训练轮数3到5轮,早停看验证集F1。损失函数是起始位置和结束位置的交叉熵之和。
有一个超参数值得单独说:无答案样本的权重。如果数据集中大部分问句都是无答案的(比如类型很多但每个句子只包含少数类型),无答案样本会占主导,模型容易倾向于预测无答案。实操中可以对无答案样本降权,或者控制正负样本比例。
# 训练命令示例 python train.py \ --model_name bert-base-chinese \ --learning_rate 3e-5 \ --batch_size 16 \ --epochs 5 \ --max_seq_length 512 \ --no_answer_weight 0.5 \ --output_dir ./mrc_ner_modelno_answer_weight控制无答案样本的损失权重,0.5表示降权一半。这个值需要根据数据集的无答案比例来调,无答案比例越高,权重可以设得越低。
4.4 推理与后处理
推理阶段,对每个问句,模型输出起始和结束位置的概率分布。枚举所有合法的(start, end)组合,选择概率乘积最大的组合。如果最大概率低于某个阈值,或者最佳组合是无答案位置,则判定该问句无答案。
# 伪代码示例:推理与span解码 def decode_span(start_probs, end_probs, context_length, threshold=0.1): best_score = -1 best_span = None for start in range(context_length): for end in range(start, context_length): score = start_probs[start] * end_probs[end] if score > best_score: best_score = score best_span = (start, end) if best_score < threshold: return None return best_span后处理阶段需要把BERT token的span映射回原始文本的字符span。如果之前做了token映射,这里反向映射即可。最终输出是实体类型、实体文本、起始位置、结束位置的列表。
注意:推理时枚举所有(start, end)组合的时间复杂度是O(n^2),如果原文很长,可以考虑只保留起始和结束概率最高的前K个位置,然后在这K个位置里枚举组合,把复杂度降到O(K^2)。
5. 常见问题与排查技巧实录
5.1 模型总是预测无答案怎么办
这是MRC框架下最常见的问题,尤其是类型多、正样本少的时候。排查思路分三步。
第一步,检查无答案样本比例。如果无答案样本占比超过80%,模型倾向于预测无答案是合理的。解决办法是对无答案样本降权,或者用focal loss让模型更关注难样本。
第二步,检查问句设计。如果问句和类型语义不匹配,模型可能学不到正确的类型线索,导致对所有问句都预测无答案。可以尝试换一种问句模板,或者在问句里加入类型描述。
第三步,检查学习率。学习率太高会导致模型在早期就收敛到无答案的局部最优。可以尝试降低学习率,或者用warmup让模型先学正样本。
5.2 嵌套实体抽取不完整怎么调
嵌套实体是MRC框架的强项,但实操中经常出现外层实体抽到了、内层实体漏掉的情况。原因通常是内层实体的问句和外层实体的问句在语义上太接近,模型难以区分。
解决办法有两个。一是增强问句的区分度,比如外层实体问句用“文本中的人物是谁?”,内层实体问句用“文本中人物的人名是什么?”,通过更细粒度的描述来区分。二是调整损失权重,对内层实体的正样本加权,让模型更关注内层实体。
5.3 类型混淆严重时的排查路径
类型混淆是指模型把A类型的实体预测成了B类型。排查路径如下表所示。
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 人物和组织混淆 | 问句语义太接近 | 查看混淆矩阵 | 增强问句区分度 |
| 地点和机构混淆 | 类型定义重叠 | 检查标注一致性 | 重新定义类型边界 |
| 所有类型都混淆 | 问句模板太简单 | 对比不同模板效果 | 加入示例或描述 |
| 特定类型混淆 | 训练样本不足 | 统计各类型样本数 | 补充样本或加权 |
类型混淆的根本原因通常是问句的语义区分度不够。我一般会先看混淆矩阵,找出混淆最严重的类型对,然后针对性地修改这两个类型的问句,让它们在语义上拉开距离。
5.4 推理速度慢的优化经验
MRC框架的推理速度是传统序列标注的N倍(N等于类型数量),如果类型多、文本长,推理速度会成为瓶颈。我踩过的坑和对应的优化经验如下。
第一个坑是逐问句串行推理。这是最慢的做法,每个问句都要完整前向一次。优化方法是批量并行,把所有问句和原文的拼接序列组成一个batch,一次前向处理完。
第二个坑是原文重复编码。问句和原文拼接后,原文部分在不同问句之间是重复的。优化方法是先编码原文,然后每个问句只编码问句部分,通过cross-attention交互。
第三个坑是O(n^2)的span枚举。如果原文有500个token,枚举所有(start, end)组合就是12.5万次计算。优化方法是只保留起始和结束概率最高的前20个位置,然后在这20个位置里枚举,计算量降到400次。
提示:推理优化优先做批量并行,这是收益最大、改动最小的方案。如果还不够快,再考虑共享原文编码和span枚举剪枝。
6. 框架扩展与落地建议
6.1 从扁平实体扩展到关系抽取
统一MRC框架的思路可以自然扩展到关系抽取。关系抽取的本质是给定两个实体,判断它们之间的关系类型。用MRC的视角来看,可以把关系类型构造成问句,比如“文本中人物和地点之间是什么关系?”,然后让模型从预定义的关系类型集合中选择答案。
这种扩展的好处是关系抽取和实体识别可以共享同一个框架,实体识别的输出直接作为关系抽取的输入,两个任务在同一个模型里端到端训练。实操中需要注意的是,关系抽取的问句需要同时包含头实体和尾实体的信息,问句设计比NER更复杂。
6.2 少样本场景下的问句微调策略
少样本场景是MRC框架最有价值的应用场景之一。在标注数据很少的情况下,问句本身携带的类型语义可以帮助模型快速适应新类型。微调策略上,我一般建议先在大规模通用数据上预训练,然后在目标领域的少样本数据上微调。
微调时,问句模板可以保持不变,也可以根据目标领域的语言习惯做调整。如果目标领域的实体表达方式和通用领域差异很大,调整问句模板往往能带来明显提升。比如通用领域问句是“文本中的人物是谁?”,目标领域是医学文本,可以改成“文本中的患者姓名是什么?”。
6.3 工程落地时的性能与精度平衡
工程落地时,精度和性能需要平衡。如果业务场景对延迟敏感,可以牺牲一点精度换速度,比如减少问句数量(合并相似类型)、缩短最大序列长度、用更小的backbone模型。如果业务场景对精度要求高,可以增加问句数量(细分类型)、用更大的模型、加入更多训练数据。
我个人的经验是,先用BERT-base跑一个baseline,看精度和延迟是否满足业务要求。如果不满足,再针对性优化。不要一上来就上大模型,很多时候BERT-base加上好的问句设计,效果已经足够好了。
6.4 我踩过的三个典型坑
第一个坑是问句和原文的语言不一致。有一次我用英文问句模板去处理中文数据,模型效果很差。后来改成中文问句,效果立刻提升。问句的语言要和原文的语言一致,这是最基本的要求。
第二个坑是忽略token对齐。中文BERT的tokenizer和原始分词不一致,导致标签对齐错位,模型学到的边界全是错的。后来我加了token映射层,确保标签和token一一对应,问题才解决。
第三个坑是无答案阈值设得太高。推理时如果无答案阈值设得太高,很多正确的span会被过滤掉,召回率大幅下降。后来我把阈值从0.5降到0.1,召回率提升明显,精度只降了一点点。
注意:无答案阈值的设置需要根据验证集调,不要拍脑袋定。建议从0.1开始,逐步调整,观察精度和召回的变化。
这个框架后续还可以这样扩展:把问句生成从手工模板改成自动生成,用大语言模型根据类型描述自动生成问句;把span抽取从起始结束位置预测改成生成式抽取,用seq2seq模型直接生成答案文本。这些方向都有不少探索空间,适合有兴趣的同行继续深挖。