让机器知道“他、她、它”在说谁:共指消解完整指南 | 自在学让机器知道“他、她、它”在说谁:共指消解完整指南
读下面这句话:
研究员把报告交给经理,因为她发现了一个关键错误。
“她”是研究员,还是经理?只看性别和距离,两个人都可能;加入“谁更可能发现报告里的错误”这样的事件知识,判断才会变得清楚。共指消解处理的正是这类问题:从文本中找出指向对象的表达,再把指向同一对象的表达归入同一条实体链。
这项任务看似只是替换代词,真正做起来却同时牵涉提及边界、指称性、篇章结构、说话人、常识、标注规范和聚类评测。下面我们不从术语表开始,而是沿着一条可复现的工程路线,把问题定义、数据、模型、训练、指标和上线边界连起来。
先把问题说准:提及、先行词与实体链
提及是文本片段,实体是它指向的对象
提及(mention)是文本里承担指称功能的片段。人名、代词、确定描述、组织名乃至某些事件表达都可能是提及。例如:
林老师提出了一套改版方案。她说,这位教研员会在周五演示。
三个加粗片段都指向同一个人,可以写成实体链:
C林老师={m1=林老师,m2=她,m3=这位教研员}
“实体”不等于字符串。两个片段字面完全不同,也能指向同一对象;相反,两次出现“王老师”也可能是同名的两个人。因此字符串相等只是线索,不是结论。

回指有方向,共指链没有方向
在“林老师进了会议室。她打开投影”里,“她”回指前面的“林老师”。我们把被解释的表达叫回指语,把前面提供解释的表达叫先行词。这个关系有方向:后面的“她”寻找前面的“林老师”。
共指则描述“是否指向同一对象”。对身份共指来说,它应当满足对称性和传递性:如果 m1 与 m2 共指,m2 与 共指,那么三者最终必须落在同一簇里。模型虽然常用一系列有方向的先行词链接做预测,输出仍要还原为无方向的实体簇。
不是每个代词都应该找一个对象
“它”出现在句子里,不代表它一定是实体提及。英语天气句中的虚指 it 没有现实对象;中文里的泛指“你”也常表示“任何人”,并不指向对话中的某位听者。第一次出现的“王老师”虽然是实体提及,却没有前文先行词。
这两种情形在许多模型里都由空先行词 epsilon 承担:
- 当前片段不是需要入链的提及;
- 当前片段是提及,但它是某个实体在文档中的第一次出现。
同一个符号合并了两种语义,推理会更简洁,错误分析却要把它们重新拆开。否则我们只能看到“模型选了 epsilon”,不知道它是在拒绝虚指,还是漏掉了一个真正的前文链接。
桥接关系不等于身份共指。“我走进一所学校,校长正在开会”中,“校长”依赖“学校”才能被理解,但校长不是学校本身。若产品只需要身份共指,就不应把这类关联强行合并成实体链。
1在“王老师第一次出场时,他正在整理讲义”中,哪个判断最准确?
标注规范先于模型:什么才算“正确答案”
共指任务没有脱离规范的唯一答案。训练数据先规定“什么算提及、什么关系入链、哪些边界有效”,模型再学习这个规定。如果团队没有把规范写清楚,同一句话在标注、训练和验收阶段会得到三套互相冲突的答案。
四步标注流程
可以把标注工作拆成四个连续判断:
先找候选片段。名词短语、代词、所有格、专名修饰语和某些事件表达都可能进入候选集,但候选不等于最终提及。
再判断指称性。片段是在说一个具体对象、一个泛类、一个虚指位置,还是只给主语添加属性?
接着确定关系类型。身份相同、同位解释、桥接、谓词关系和篇章指示需要分开;只做身份共指时,其余关系应保留为未链接或独立标签。
最后形成完整实体簇并做篇章级复核。逐对链接都看似合理,合并后仍可能出现性别、数、说话人或时间范围冲突。

边界、泛指、同位语和系词结构
常用的 OntoNotes 风格规范把具体实体或事件的名词短语、代词、所有格等纳入身份链,也单独处理同位结构。下面几类边界尤其容易误判:
- 最大逻辑片段:在“那位来自上海的产品经理”里,若完整短语才稳定指向对象,不能只截“经理”而丢掉限定成分。
- 泛指表达:泛指名词之间通常不直接互链,但泛指名词与随后回指它的代词可能构成链。团队要明确自己是否沿用这一做法。
- 虚指表达:没有可替换实体的虚指代词不入链。一个实用检查是尝试用具体名词短语替换它;若替换后句义崩坏,它很可能不是普通指称。
- 同位结构:“周宁,项目负责人”中两个相邻片段共同说明一个对象,但规范可以把它记为专门的同位关系,再把整个同位结构接入外部身份链。
- 系词结构:“周宁是项目负责人”里的“项目负责人”可能只是属性。在某些规范中,主语与表语不标身份共指;其他语义方案可能保留谓词关系。
- 单例实体:只出现一次的实体对提及检测很有用,但有些主流基准不标单例。此时“提及识别分数”和“链接分数”无法完全独立计算。
“语义上像同一对象”不保证“按当前规范应当成链”。评测前必须冻结提及边界、单例、泛指、同位语、系词、事件共指和桥接关系的处理方式,并让训练集与验收集遵守同一版本。
中文还要处理零代词与说话视角
中文经常省略可从上下文恢复的主语或宾语:
林老师看完报告,随后 ∅ 提出了三条修改意见。
这个空位能否成为提及,取决于语料层是否显式标出零代词,以及当前任务是否把它纳入评分。对话中“我、你、我们”还依赖说话人和听话人;不记录发言者,仅凭词面几乎不可能稳定成链。
同一文本在不同规范下可能有不同金标准。例如,“小周是工程师”可以被一种方案视为两个不共指片段,另一种方案则记录主语与谓词名词的语义关系;“一辆车……司机”可以只保留两个实体,也可以额外标桥接。正确做法不是争论哪套“更真实”,而是先选择与产品目标一致的范围。
3只要两个名词短语在现实世界中指向同一个人,任何共指标注规范都必须把它们放进同一身份链。
从规则、成对分类走向全局聚类
规则系统先做“不可能性过滤”
一个实用基线通常先生成候选,再用明确约束排除不可能的先行词:
- 先行词必须出现在当前提及之前;
- 人称、数、语义类型和句法绑定不能明显冲突;
- 对话中的“我”优先落到当前说话人,“你”要结合受话人;
- 超出上下文窗口的候选可以降权,但不宜一刀切;
- 两个不同专名默认不合并,除非别名、简称或上下文给出充分证据。
规则的价值不只在精度。每次拒绝都有清楚原因,适合做候选审计和高风险场景的安全阀。问题也很直接:语言会用转喻、昵称、角色变化和长距离回指突破规则,过严的过滤会在打分前永久删掉正确答案。
成对分类为什么容易破坏传递性
早期统计系统常把每对提及 (mi,mj) 送进二分类器,预测“共指/不共指”。特征包括距离、字符串、数与性别、主语显著性、命名实体类型和句法关系。
逐对判断可能产生:
m1∼m2,m2∼m
如果最后用连通分量聚类,前两条正链接会把三者合并,第三条负判断被覆盖;若不做闭包,输出又不再是合法实体簇。这说明局部分类目标与最终聚类结构并不一致。
排序模型把“链接到谁”作为一次决策
提及排序不再逐对独立判真,而是让当前提及在所有先行候选和 epsilon 之间排序。实体排序进一步比较“当前提及加入哪个已有簇”。这两种做法把决策目标推近了最终结构,也能表达“几个候选都不够好,宁可开启新实体”。
上面的实验台把规则层和聚类层分开呈现。你可以关闭语义类型约束,观察人物代词怎样获得“方案”这样的错误候选;再建立几条局部链接,看它们怎样通过并查集合并成实体链。这样设计的原因是:候选召回和链接精度是两类问题,混成一个总分很难定位系统究竟错在哪一步。
6若成对分类器认为 A-B、B-C 共指而 A-C 不共指,直接把正链接求连通分量会让 A、B、C 进入同一簇。
端到端 span 排序:同时学会找提及和找先行词
端到端模型把“提及检测器输出什么”也纳入训练。文档有 T 个词时,所有连续片段的数量是:
N=2T(T+1)
模型先把每个不超过最大宽度 L 的片段当成候选,再学习它是不是有价值的提及。这样能避免手工提及检测的错误直接锁死后续链接,但候选空间也会迅速变大。

一个 span 怎样变成向量
片段“那位负责检索系统的工程师”不能只用平均词向量表示。一个常见 span 表示把四类信息拼在一起:
gi=[xstart(i)
- 首、尾上下文向量帮助模型判断边界;
- x^i 是片段内部的注意力加权和,近似一个为当前任务学习出来的“软中心词”;
- ϕ(i) 可以编码片段宽度等离散信息;
- 现代编码器还会直接给每个子词提供上下文化表示。SpanBERT 之类的预训练方式通过连续片段遮盖和边界目标,让编码器更习惯恢复完整 span,因此很适合提及选择。
提及分数、成对分数和空先行词
对当前 span i 和前文候选 j,模型通常组合三个分数:
s(i,j)={0,s
sm 判断片段像不像提及,sa 判断两个片段的先行关系。成对网络可以读取 gi、、逐元素乘积、距离桶、体裁和说话人信息:
sa(i,j)=wa
固定 s(i,ϵ)=0 给了模型一个清楚的拒绝基准:若所有非空候选都低于 0,就选择 epsilon。这不会自动区分“新实体”和“非提及”,但能避免模型被迫链接到一个很差的前文对象。
剪枝不是实现细节,而是召回率预算
若枚举所有 span 对,粗略复杂度可达 O(T4)。实际系统会限制最大 span 宽度,只保留提及分数最高的约 λT 个候选,并为每个候选保留至多 K 个先行词。粗排还可以用便宜的双线性分数,细排再调用更重的网络。
每次剪枝都在换取速度,也可能永久删除金标准提及或先行词。因此要同时报告:
- 金标准提及在剪枝后的召回率;
- 金标准先行词进入前 K 候选的比例;
- 不同文档长度下的显存、延迟与召回变化;
- 人名、代词、长名词短语和事件提及各自的剪枝损失。
边际似然与高阶推理:别让一次局部选择锁死整条链
把候选分数归一化
对 span i,候选集合 Y(i) 包括所有保留的前文 span 和 epsilon。先行词概率是:
P(yi=j∣D)=
若“这位教研员”所在金标准链此前已经出现“林老师”和“她”,两者都是正确先行词。训练不必武断地指定其中一个,而是最大化所有正确候选的概率和:
L=−i∑logj∈Y(i)∩GOLD(i)
这就是边际似然。它奖励“链接到同一金标准簇里的任一前文提及”,把具体链接视为潜变量。若正确先行词在剪枝时全被删掉,训练实现通常把 epsilon 作为当前可见的正确项;这能保持目标可算,却也提醒我们必须单独监控剪枝召回。
实验台把三个分数和 epsilon 放在同一张概率表里。切换“这位教研员”和“王老师”,你会看到:前者的正确集合包含多个先行词,概率应相加;后者是新实体,只有把非空候选压低,epsilon 才会获胜。这个对照能防止把“高提及分数”误解为“必须链接前文”。
一阶排序看提及对,高阶推理看整个簇
局部模型可能分别认为“我—你”和“你—你们”合理,合并后却得到一个同时包含单数、复数和不同说话人的矛盾簇。高阶模型用上一轮先行词分布对候选表示做加权汇总:
ai(t)=j∈Y(i)∑
然后用门控把原表示与聚合表示合并,再重新打分。第一轮是“这两个片段像不像”,下一轮则带上“它们可能属于哪些实体”的软信息。这样能让后续决定感知簇级性别、数和说话人一致性。

高阶推理也有边界。错误的第一轮分布会把噪声传给表示;迭代越多,成本越高,收益通常很快递减。粗到细方案先用便宜因子筛候选,再对小集合做高阶更新,关键仍是保证粗排没有漏掉正确路径。
9边际似然要求模型在多个正确先行词中事先挑定唯一一个作为监督标签。
长文档、跨句与说话人:上下文不是越长越好
分块会让实体在边界处“失忆”
短句里的代词常能靠距离解决,长报告、访谈和小说却可能隔几十段重新提到同一人。直接把文档切成互不相干的块会产生两个问题:
- 先行词落在上一块时,当前块只能把回指语误判为新实体;
- 各块都建立自己的“张主任”簇,后处理再合并时缺少原始上下文。
长文档基准显示,早期数据制作中的文档切分会缩短实体链,也会低估真实推理距离。工程上可以结合滑动窗口、跨块实体记忆、候选检索和第二阶段全局合并,但每种机制都要保留可追溯证据:当前链接来自原文哪两个片段,是否跨块,以及中间经过了哪次别名归一。
说话人改变“我”和“你”的候选空间
看一段对话:
甲:我已经上传了报告。
乙:你能把附录也发给我吗?
甲的“我”和乙的“你”指向甲;乙的“我”指向乙。若把四个代词仅按字符串聚类,结果一定错。对话系统至少要给每个 token 或话轮附上说话人标识;若能获得受话人,还可把“你”的候选进一步收窄。转述和嵌套引语会改变视角中心,因此不能把整篇文档的作者当成所有第一人称的指称对象。
上下文长度要与候选检索配合
把整个长文档一次塞进编码器并不自动解决问题。更长序列会稀释局部线索、增加显存和延迟,还可能因为截断导致末尾片段没有完整上下文。一个可控方案是:
- 局部编码保留句内和邻句细节;
- 实体记忆保存代表性名称、最近提及、说话人和类型摘要;
- 检索层召回远距离候选,并显式记录召回分数;
- 全局层只在少量高置信候选间做一致性推理;
- 对跨文档同名消歧另设任务,不把它悄悄混入文内共指。
跨文档共指还需要时间、来源、知识库标识和事件上下文。“华星公司”在两篇文章里可能是同一机构,也可能是简称冲突;只因名字相同而跨文档合并,会把来源不同的事实错误归到同一实体。
12对话中解析第一、第二人称代词时,最直接需要哪类元数据?
MUC、B³、CEAF 与 CoNLL 平均:三把尺子看三个侧面
设金标准实体簇集合为 K,系统预测簇集合为 R。共指输出是一个分区,不是独立标签列表,因此不同指标会强调不同结构。

MUC:最少还缺多少链接
对金标准簇 Ki,用预测簇去切分它,得到分区 p(Ki)。MUC 召回率是:
RMUC=∑i(∣
精确率把金标准与预测交换。它衡量让每个簇内部连通还缺多少链接,对“大量提及误并成一个簇”较宽容,也不擅长反映单例质量。
B³:每个提及周围有多少同伴正确
令 K(m) 和 R(m) 分别是包含提及 m 的金标准簇和预测簇:
Pm=∣R(m)∣∣
对全部提及的 Pm、Rm 分别求平均,再算 F1。大簇里混入错误实体会降低很多提及的精确率,所以它比 MUC 更敏感于簇污染。
CEAF:给实体做一对一最优对齐
实体版 CEAF 常用相似度:
ϕ4(Ki,Rj)=
然后寻找金标准簇与预测簇之间的一对一映射 π,使总相似度最大:
π∗=argπmaxi∑
一个预测大簇不能同时完美对齐多个金标准实体,因此过度合并会受到明显惩罚。求最优映射可转化为最大权二分匹配。
CoNLL 分数是并列三种偏好,不是终极真相
常用 CoNLL 分数取三个 F1 的无权平均:
FCoNLL=3F
实验台中的七个提及有三条金标准链。把所有提及误并成一簇时,实际计算得到:MUC F1 为 0.8000,B³ F1 为 0.5152,CEAFₑ F1 为 0.3000。MUC 看到金标准链都已在大簇中连通,所以召回仍为 1;CEAFₑ 只允许大簇对齐一个实体,因而给出最强惩罚。
评测实现也属于实验条件。预测提及与金标准边界不完全一致时,B³ 和 CEAF 曾出现多种处理版本。正式对比应固定公开参考评分器、提及匹配规则和版本号,不要在业务脚本里凭印象重写公式。
13为什么把所有提及并成一个大簇时,MUC 召回仍可能很高?
14CoNLL 平均分相同的两个系统,错误结构和下游风险一定相同。
把共指系统做成可审计组件
错误分析要沿流水线回溯
只看最终实体簇,很难知道应改数据、候选还是模型。建议把错误分成至少六类:
- 提及漏检或边界错:正确对象根本没进入候选,或片段截断;
- 指称性错:把虚指、泛指或谓词属性当成实体;
- 候选召回错:正确先行词被距离、类型或剪枝规则删掉;
- 局部排序错:正确候选仍在,但分数低于错误对象或 epsilon;
- 聚类传播错:一条错误桥接把两个大簇整体合并;
- 规范错位:模型按一种范围训练,验收却把单例、系词或桥接按另一种方案计分。
每条错误都应保存文档片段、提及边界、候选列表、过滤理由、分数、最终簇和规范版本。这样才能回答“模型为什么把两个人合并”,而不只是返回一个不透明链编号。
一个真正运行过的最小实验
下面的程序用七个提及演示三件事:候选约束、先行词链接形成聚类,以及 MUC/B³ 的结构差异。CEAFₑ 的完整一对一对齐放在本节交互实验台里;生产评测应使用固定参考评分器。
from collections import defaultdict
mentions = [
{"id": "m1", "text": "林老师", "kind": "person", "sent": 0, "proper": True},
{"id": "m4", "text": "改版方案", "kind": "thing", "sent": 0
运行:
关键输出是:
links: {'m1': None, 'm4': None, 'm2': 'm1', 'm5': 'm4', 'm3': 'm2', 'm6': None, 'm7': 'm6'}
rejected: 13 / 20
clusters: [['m1', 'm2', 'm3'], ['m4', 'm5'], ['m6', 'm7']]
MUC: (1.0, 1.0, 1.0)
B3: (1.0, 1.0, 1.0)
这个小实验故意不用训练模型,因为我们先要验证结构:约束是否真的过滤了候选,链接能否稳定形成传递闭包,指标能否区分“拆链”和“误并”。这些基础性质对齐后,再替换成神经分数,定位问题会容易得多。
偏差、隐私和应用边界
共指系统会吸收职业与性别的统计关联。WinoBias 一类成对测试让句法结构保持不变,只交换代词性别或把正确答案从刻板职业换到反刻板职业。如果两组 F1 差距很大,说明模型在利用职业刻板印象。修复时应同时做反事实数据增强、分组评测和人工误差复核,不能只看总分是否下降。
Winograd 风格样例适合检查常识线索能否让答案翻转,但小型挑战集上的高分不等于开放长文档已经解决。真实文本还有提及漏检、多个说话人、嵌套引语、体裁迁移和标注边界问题。跨体裁实验也反复表明,在熟悉基准上表现好的模型,换到访谈、教程或小说时可能明显退化。
隐私风险常被低估。共指会把分散在文档各处的姓名、职务、健康信息和行为描述聚合到一个人身上,这种聚合本身就增加了可识别性。处理敏感文本时应:
- 在进入模型前做数据分级与最小化,只保留任务需要的上下文;
- 限制原文、实体链和调试日志的访问范围与保存时间;
- 不把链编号误当匿名化,稳定链仍可能通过上下文重新识别个人;
- 对人事、医疗、信用和执法等高风险决定设置人工复核与拒答路径;
- 删除请求要同时覆盖原文、缓存、向量索引、实体记忆和导出的结构化结果。
在问答里,共指能把“她提出了什么”扩展回正确人物;在信息抽取里,它能把同一公司的别名和代词合并到一张事件模板;在摘要里,它能保持人物称谓一致。但下游系统不应把低置信链接改写成确定事实。更稳妥的接口是同时返回实体簇、逐链接置信度、证据片段、规范版本和可拒绝状态。
16把姓名替换成稳定链编号后,共指结果就不再包含隐私风险。
m3
3
,
m1
∼
m3
∗
;
xend(i)∗
;
x^i
;
ϕ
(
i
)]
m
(
i
)
+
sm
(
j
)
+
sa
(
i
,
j
)
,
j=ϵj=ϵ
gj
⊤
FFNNa
([
gi
;
gj
;
gi
⊙
gj;ϕ(i,j)])
∑
j′∈Y(i)
exp
s
(
i
,
j′
)
exps(i,j)
∑
P
(
yi
=
j∣
D)
P(t−1)
(
yi
=
j)gj(t−1)
Ki
∣
−
1
)
∑i(∣Ki∣−∣p(Ki)∣)
K
(
m
)
∩
R
(
m
)
∣
,
Rm
=
∣K(m)∣∣K(m)∩R(m)∣
∣Ki∣+∣Rj∣2∣Ki∩Rj∣
ϕ
4
(
Ki
,
Rπ(i)
)
MUC
+
FB3
+
FCEAFe
,
"proper"
:
False
},
{"id": "m2", "text": "她", "kind": "person", "sent": 1, "proper": False},
{"id": "m5", "text": "它", "kind": "thing", "sent": 1, "proper": False},
{"id": "m3", "text": "这位教研员", "kind": "person", "sent": 2, "proper": False},
{"id": "m6", "text": "王老师", "kind": "person", "sent": 2, "proper": True},
{"id": "m7", "text": "他", "kind": "person", "sent": 3, "proper": False},
]
gold = [{"m1", "m2", "m3"}, {"m4", "m5"}, {"m6", "m7"}]
def allowed(cur, ant):
if ant["sent"] > cur["sent"]:
return False
if cur["sent"] - ant["sent"] > 2:
return False
if cur["kind"] != ant["kind"]:
return False
if cur["proper"] and cur["text"] != ant["text"]:
return False
return True
parent = {m["id"]: m["id"] for m in mentions}
def find(x):
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
def union(a, b):
parent[find(a)] = find(b)
links = {"m1": None, "m4": None}
rejected = total = 0
for i, cur in enumerate(mentions[2:], start=2):
candidates = []
for ant in mentions[:i]:
total += 1
if not allowed(cur, ant):
rejected += 1
continue
score = 2 - 0.25 * (cur["sent"] - ant["sent"])
candidates.append((score, ant["id"]))
if cur["proper"] or not candidates:
links[cur["id"]] = None
else:
links[cur["id"]] = max(candidates)[1]
union(cur["id"], links[cur["id"]])
groups = defaultdict(set)
for m in mentions:
groups[find(m["id"])].add(m["id"])
pred = list(groups.values())
def f1(p, r):
return 0 if p + r == 0 else 2 * p * r / (p + r)
def muc(key, response):
def side(source, other):
num = den = 0
for cluster in source:
parts = sum(bool(cluster & target) for target in other)
num += len(cluster) - parts
den += len(cluster) - 1
return num / den if den else 0
r, p = side(key, response), side(response, key)
return p, r, f1(p, r)
def b3(key, response):
key_of = {m: c for c in key for m in c}
res_of = {m: c for c in response for m in c}
ms = list(key_of)
p = sum(len(key_of[m] & res_of[m]) / len(res_of[m]) for m in ms) / len(ms)
r = sum(len(key_of[m] & res_of[m]) / len(key_of[m]) for m in ms) / len(ms)
return p, r, f1(p, r)
print("links:", links)
print("rejected:", rejected, "/", total)
print("clusters:", sorted(sorted(c) for c in pred))
print("MUC:", tuple(round(x, 4) for x in muc(gold, pred)))
print("B3:", tuple(round(x, 4) for x in b3(gold, pred)))