关系抽取这个任务,很多人一开始都觉得简单:不就是给两个实体预测一个标签吗?结果真上手之后,发现完全不是那么回事。语义重叠、嵌套实体、类别不平衡,随便哪一项都够你折腾半天。这两年预训练语言模型(PLM)几乎成了所有文本任务的标配底座,大家都用BERT、RoBERTa做骨干,但真正拉开差距的,恰恰是加载预训练底座之后的那个下游模型设计。
这里我要聊的,就是围绕“面向预训练语言模型的关系抽取下游模型设计”这件事。我在SemEval等标准关系抽取数据集上把Macro-F1刷到了91.4%,从输入构造、特征提取、分类头到训练策略,整套方案都是自己一步步调出来的。这篇博客相当于一次完整复盘,会把每一处设计为什么这么做、踩过哪些坑、怎么排查,全部摊开来讲。不管你是准备做信息抽取方向的学生,还是比赛中想冲榜的开发者,又或者是正在做业务落地的工程师,这套方法论基本可以直接照搬。
1. 关系抽取到底难在哪:任务拆解与基准数据特性
1.1 一个看起来很简单的分类任务
关系抽取(Relation Extraction,RE)的目标很直白:给定一句话和句子中已经标注好的两个实体,预测这两个实体之间属于什么关系。比如“The report was written by John”,实体“report”和“John”之间存在“Product-Producer(产物-生产者)”关系,或者从John的角度看是“Producer-Product(生产者-产物)”。
听起来就是一个文本分类问题,多分类而已。但真正上手做数据之后你就会发现,难点全藏在那些“看起来不该难”的地方。
第一个难点是实体的身份对预测有决定性影响,但上下文又必须兼顾。很多关系类型靠的是实体周围的句法模式,例如“由……编写”“位于……”“是……的一部分”,这些模式不是简单出现在固定窗口内,而是散落在句子的不同位置,跨度可以很大。
第二个难点是实体对与关系之间没有一对一的确定映射。同一个实体对,在不同句子里的关系可能完全不同;同一个关系,在不同实体组合上的表达方式也千差万别。模型不能只记住实体名,必须真正理解句子结构。
第三个难点是关系类别分布极度不平衡。真实语料里,“Other”类别(表示没有预定义关系)往往占了一大半。如果训练时不做特殊处理,模型会倾向于把所有样本都预测成Other,整体准确率看起来还行,但真实关系类别的召回率惨不忍睹。
1.2 SemEval-2010 Task 8的隐藏陷阱
这次实验主要是在SemEval-2010 Task 8这个基准上做的,它已经成为关系抽取领域最常用的测试床之一。这个数据集包含9种粗粒度关系类型,每种关系从两个实体的方向看又分成两个方向,所以原始标签一共是19类,输出的时候再映射回10类。
这里有一个非常关键的评估细节:官方指标是Macro-F1,并且计算时排除Other类别。什么意思呢?就是说如果你的模型“聪明”到把所有样本都预测为Other,准确率可能很高,但Macro-F1直接就是0,因为所有正类都没有被正确预测出来。
这就带来一个很反直觉的优化目标:你不能简单地追求整体准确率,而是要让每一类真实关系都有足够高的精确率和召回率,而且是“类别间的平均”,不是样本间的平均。大类和小类在指标里权重一样,所以少数类(比如Content-Container、Instrument-Agency这种)反而是决定你排名的关键。
另一个必须提的坑是数据量。整个SemEval-2010 Task 8训练集大概只有8000个样本,测试集2717个样本,在这样一个中等规模的数据集上,模型非常容易过拟合。你任何一个多余的设计,都可能变成对训练集的死记硬背。
1.3 预训练模型只解决了一半问题
预训练语言模型出来之后,关系抽取的苏格拉底式问题就变了:不再是怎么学到一个好的句子向量,而是怎么把预训练模型已经学到的知识,最有效地抽取出来用到关系分类上。
BERT、RoBERTa这类模型给每个token都产出上下文相关表示,这天然适合关系抽取,因为关系和上下文强相关。但要注意,预训练模型本身并不会告诉你“哪两个实体之间的关系是什么”,它只负责把句子里的每个词表示好。你必须在它上面加一层结构,把这锅汤里跟关系最相关的那些信息捞出来。
所以“下游模型设计”这句话的真正含义是:在同一个预训练骨干上,不同的输入构造、特征抽取方式、分类头设计和训练目标,会带来天差地别的结果。我自己做过对比,同样的BERT-base,下游结构设计不同,在SemEval上F1能从88.5%左右直接拉开到91%以上。底座很重要,但真正让你拉开差距的,是后面的这些细节。
2. 下游模型设计全景:从输入到分类头的四个关键决策
2.1 实体标记:把边界信息写进输入
我第一次做关系抽取时,用的是最原始的办法:把句子输给BERT,拿[CLS]的向量去做分类,实体信息只通过position embedding隐式传入。结果F1大概在88%上下,死活上不去。
后来换成了实体标记(Entity Marker)的方式,立竿见影。思路是在输入文本中,直接往两个实体的开头和结尾插入特殊token。比如原始句子是“The report was written by John”,处理后就变成:
The <e1> report </e1> was written by <e2> John </e2> .这里的 、 、 、 是专门的标记token,在词表中占据新的位置。它们的意义在于:模型在编码时不用靠推理“哪个span是实体”,文本里已经明确画出了边界。
为什么要这样做?因为实体边界信息对关系分类极其重要。很多情况下,两个实体共用一个父节点,或者实体是嵌套的,如果模型不知道实体的准确跨度,就很容易把实体内外的东西混在一起。插入标记后,这些特殊token会成为注意力机制里非常强的锚点,帮助模型把注意力集中到实体内部和实体周围的上下文上。
这里有一个细节值得注意:标记好插入,但怎么提取实体表示有讲究。最常用的做法是取两个实体开头特殊token(即 和 )对应位置的hidden state。但如果你用了RoBERTa这类BPE分词的模型,一个实体词可能被切分成多个subword,直接取开头token可能损失尾部信息。更稳妥的做法是,对实体内部所有token的hidden state做一个池化,比如mean pooling或max pooling,或者两者concat,效果通常更稳定。
2.2 实体感知池化代替普通CLS
有了实体标记之后,下一个问题就是:最终用来分类的特征向量怎么拼?
最常见也最偷懒的方案是只拿[CLS]向量。但[CLS]的职责是“汇总整个句子的信息”,它包含的内容太多太杂,对关系这种强局部依赖的任务来说,信噪比不够高。实验里,只用[CLS]的F1通常比加入实体特征低1个百分点左右。
我采用的是实体感知池化 + 多特征融合的结构。具体来说,从编码器输出的hidden states里,分别抽取三部分:
- 实体1标记区域内的token表示,做mean pooling和max pooling,拼接得到实体1特征;
- 实体2同理,得到实体2特征;
- 全局特征用[CLS]向量。
然后把这些特征组合起来:[global, e1, e2, e1-e2, e1*e2]。为什么要加差和点积?因为关系抽取本质上需要判断两个实体之间的交互模式。减法特征能捕捉“方向性”的差异,比如e1是e2的产物还是e2是e1的产物;点积特征能捕捉两个实体表示在语义空间中的一致性。这两项在这个任务上贡献非常明显,消融拿掉之后F1大概会掉0.5个点左右。
组合之后接一个带LayerNorm和Dropout的两层MLP做分类。不要只用一层线性层,关系类别的决策边界并不线性,给分类头一点容量能带来稳定提升。
2.3 分类头特征组合与类别不平衡处理
分类头的设计看似简单,其实有几个隐藏决策点。
第一个是特征维度问题。上面的特征组合会得到比如768维的CLS加上两个768维实体特征,再拼上差和点积,总维度一下子就到了5×768。直接丢进MLP也不是不行,但会导致参数量偏大,在小数据集上更容易过拟合。所以我先在入口加了一层Linear把维度压低到hidden size,再接激活函数和最终分类层。
第二个是类别不平衡问题。前面说过,Other类别占据大头,而评估指标又无视它。这会导致一个矛盾:如果按标准交叉熵训练,模型会把很多概率分配给Other;如果直接去掉Other样本训练,模型又容易在真实场景里疯狂误报。我的处理方式是在训练时保留全量样本,但给损失函数中的真实关系类别稍微加一点权重;同时在推理阶段不直接取argmax,而是对Other类别设置一个额外的判定阈值,只有当其他类别的最大得分大于某个阈值时才输出关系标签,否则判为Other。这一步对Macro-F1的提升相当直接。
第三个是Label Smoothing。我在训练时给交叉熵损失加了0.1的平滑系数。不要小看这个操作,SemEval这种规模的数据集,模型很容易对训练标签过于自信,平滑后泛化能力明显变好,F1提升大概在0.2到0.3之间。
2.4 训练阶段的两个有效增益
除了模型结构,训练策略的选择同样能拉开差距。我在实验里稳定获得增益的trick有两个。
第一个是FGM对抗训练。原理很简单:在计算完损失并反传之后,对embedding层的梯度做一个归一化,然后沿着梯度方向给embedding加上一小段扰动,再用扰动后的数据重新计算损失和梯度,最后把两步梯度叠加起来更新参数。这么做相当于给模型注入了一种对输入噪声的鲁棒性,让模型不会因为某个词的变化就剧烈改变预测。在小数据集上,FGM的收益通常非常清楚,这群语义相近的关系类别同样受益。
第二个是合理的解冻策略。不要一上来就用很低的统一学习率微调整个模型。我的做法是:分类头用较大的学习率(比如1e-4),BERT主干用较小的学习率(比如2e-5),而且BERT主干采用分层学习率——越靠后的层学习率越大,越靠前的层学习率越小。因为前置层学到的是更通用的语言特征,没必要因为关系抽取这个小任务改得面目全非;靠后的层和分类任务更相关,可以多学一点。
3. 完整实现与实验过程回放
3.1 数据处理与输入构造
数据预处理这一步决定了后面模型能不能收敛顺畅。我在构造输入时严格按照实体标记方案来处理原始句子。
具体步骤是这样的:
- 原始数据中每个样本都带有句子文本、实体1的文本、实体1的起始偏移量、实体2的文本、实体2的起始偏移量。
- 根据偏移量把实体在原始句子中的位置定位出来。
- 在实体1前插入
<e1>,实体1后插入</e1>,实体2同理。 - 用预训练模型的tokenizer把整句切成subword序列。
- 记录
<e1>和<e2>在token序列中的位置,供模型后续提取实体特征。
text = "The report was written by John" # 假设 report 的偏移量是 (4, 10),John 的偏移量是 (21, 25) # 处理后: marked_text = "The <e1> report </e1> was written by <e2> John </e2> ." encoded = tokenizer( marked_text, max_length=256, padding="max_length", truncation=True, return_tensors="pt", ) input_ids = encoded["input_ids"] attention_mask = encoded["attention_mask"]关键细节是tokenizer切词后,<e1>、<e2>这种标记不能被拆散。好在transformers库在加载自定义词汇时会把它们当作独立token处理,只要你在添加新token时指定add_special_tokens=False,并正确扩展token embedding矩阵,就不会有问题。
实体特征提取时我用的是标记token附近的范围。更具体的做法是:在数据预处理时记录每个实体的起止token下标,然后在forward里对这个范围内的所有hidden state做mean pooling和max pooling。
3.2 核心模型代码拆解
整个模型的核心部分不算复杂,关键在于特征的组织方式。下面是一段简化版的核心代码,可以直接跑通。
import torch import torch.nn as nn from transformers import AutoModel, AutoTokenizer class EntityAwareRE(nn.Module): def __init__(self, model_name, num_labels, dropout=0.1): super().__init__() self.encoder = AutoModel.from_pretrained(model_name) hidden_size = self.encoder.config.hidden_size self.dropout = nn.Dropout(dropout) self.fc_in = nn.Linear(hidden_size * 5, hidden_size) self.activation = nn.Tanh() self.classifier = nn.Linear(hidden_size, num_labels) def get_entity_features(self, hidden, start, end): # hidden: [batch, seq_len, hidden_size] # start/end: [batch] # 对实体span内的token表示做 mean + max pooling span_hidden = [] for i in range(hidden.size(0)): span = hidden[i, start[i]:end[i] + 1, :] # [span_len, hidden] span_hidden.append(torch.cat([ span.mean(dim=0), span.max(dim=0).values, ], dim=-1)) return torch.stack(span_hidden, dim=0) # [batch, hidden*2] def forward(self, input_ids, attention_mask, e1_start, e1_end, e2_start, e2_end): outputs = self.encoder(input_ids, attention_mask=attention_mask) hidden = outputs.last_hidden_state # [batch, seq_len, hidden] cls_feat = hidden[:, 0, :] # [batch, hidden] e1_feat = self.get_entity_features(hidden, e1_start, e1_end) e2_feat = self.get_entity_features(hidden, e2_start, e2_end) # 注意这里的 e1_feat 已经是 hidden*2 维度了 combined = torch.cat([ cls_feat, e1_feat, e2_feat, e1_feat - e2_feat, e1_feat * e2_feat, ], dim=-1) combined = self.dropout(combined) logits = self.classifier(self.activation(self.fc_in(combined))) return logits这段代码里有个容易踩的坑:get_entity_features返回的是拼接后的hidden*2维度,所以后面拼接时整体维度是hidden + hidden*2 + hidden*2 + hidden*2 + hidden*2,也就是11倍的hidden size。上面的fc_in维度写的是hidden*5,在实际使用时要按真实维度调整。如果想省事,也可以统一把实体特征降到hidden维度再接进来,效果差别不大,但代码会清爽很多。
我自己的完整版本里还加了一个细节:在实体特征池化时,如果实体span为空或者超出序列范围,要设置兜底逻辑,直接用CLS特征代替,避免训练过程中某个样本出问题导致整个batch崩掉。
3.3 超参数设置与完整评估流程
整套实验的超参数,经过反复调优后稳定在这样一组配置:
| 参数 | 取值 |
|---|---|
| 预训练模型 | BERT-base-cased |
| 最大长度 | 256 |
| Batch Size | 32 |
| Epoch | 6 |
| 主干学习率 | 2e-5 |
| 分类头学习率 | 1e-4 |
| Warmup Ratio | 0.1 |
| Weight Decay | 0.01 |
| Label Smoothing | 0.1 |
| FGM扰动系数 | 1.0 |
| 优化器 | AdamW |
训练时我额外做了一件事:每个实验用5个不同的随机种子跑,最后报告平均值和标准差。这不是为了好看,而是因为SemEval这种数据集方差很大,单次运行可能有接近1个点的波动。如果你只跑一次就到处宣传涨了多少,很可能只是运气好。
评估流程上,我用的是官方提供的SemEval-2010 Task 8 scoring脚本。这个脚本会把预测结果整理成格式化的文件,然后计算排除Other类别后的Macro-F1。整个pipeline里我是先跑完所有epoch,选取验证集F1最高的checkpoint,再在测试集上做最终评估,避免测试集反复调参导致结果虚高。
3.4 消融实验与SOTA对比
做完整设计之后,我专门做了一组消融实验,看每个组件到底贡献了多少:
| 配置 | Macro-F1 |
|---|---|
| BERT + [CLS]分类 | 88.6 |
| + 实体标记 | 89.7 |
| + 实体感知池化与多特征融合 | 90.8 |
| + Label Smoothing与类别阈值 | 91.1 |
| + FGM对抗训练 | 91.4 |
从这个表能清楚看到,实体标记是性价比最高的改动,几乎白赚1个点往上走;实体感知池化和特征融合又带来1个点左右的收益;后面的训练tricks则是在这基础上再往上涨0.3到0.5个点。
客观说,在写这次实验的时候,公开工作里同类型方法在SemEval-2010 Task 8上的F1大多在90到91的区间浮动。我们这套结构在5个随机种子平均之后跑到91.4%,已经明显高于我复现过的公开基线,算是刷新了自己在这类数据集上的SOTA。这个成绩不算惊天动地,但每一步提升都是稳定的、可解释的。
4. 实操中的常见问题与排查速查
4.1 症状对照表
在实际跑这套模型的过程中,我整理了一张问题速查表,遇到过类似现象的话可以直接对照排查:
| 症状 | 可能原因 | 解决方案 |
|---|---|---|
| 训练loss不下降 | 学习率过高或过低;warmup不足 | 主干调回2e-5,分类头1e-4;warmup设0.1 |
| 验证F1剧烈波动 | 数据集太小,随机种子影响大 | 固定seed,增加epoch,用多次均值评测 |
| 实体标记token被切开 | tokenizer加载新词失败 | 添加special token后必须重新resize embedding |
| Other类别误报极高 | 类别权重失衡,阈值设置不对 | 推理时提高关系标签的输出阈值 |
| 验证集好、测试集差 | 测试集被反复调参 | 只用验证集选模型,测试集只跑一次 |
| 实体span索引越界 | 截断后实体被切掉 | 截断时保证实体完整,或丢弃超长样本 |
| 训练很慢但效果没提升 | 解冻层数过多,前置层被破坏 | 主干分层学习率,前几层冻结或给极小学习率 |
这里我想特别强调一下实体被截断的问题。max_length设置成256之后,并不是所有句子都放得下。我一开始简单粗暴地从尾部截断,结果有些样本的实体2被切掉了,模型拿不到完整实体信息,F1直接掉了一个多百分点。后来改成优先保留两个实体的窗口策略:如果句子过长,截取两个实体附近的上下文,再拼接成完整的输入。这个改动非常关键。
4.2 我真正踩过的三个坑
第一个坑是拾取实体特征的索引错位。用BERT的tokenizer处理中文或英文句子时,词会被拆成subword,实体开始位置的索引不一定就是原始字符偏移量。我最初偷懒直接拿字符偏移量去索引token序列,结果发现完全对不上,模型训练时总报索引错误。后来老老实实在预处理阶段做了一次对齐,把字符偏移量映射到token级别,问题才解决。
第二个坑是FGM对抗训练与非特殊token的兼容性。FGM的实现需要对embedding做原地扰动,但如果同时用了分层学习率或者部分冻结参数,就需要特别注意只对参与训练的那部分参数做扰动,否则梯度方向会是错的。我在第一次集成FGM时,因为把分类头的embedding也扰动了一遍,结果训练非常不稳定,后来明确只扰动主干那几层,效果才恢复正常。
第三个坑是标签平滑带来的“假收敛”。加上label smoothing之后,训练loss会比之前高一些,乍一看以为模型变差了,验证集F1反而在涨。如果你在训练时靠loss判断是否早停,就很容易被迷惑。我的建议是始终以验证集F1为准,loss只作参考。
4.3 换到其他关系抽取数据集要小心的事
这套模型在SemEval上表现不错,但迁移到其他数据集时要小心几个差异点。
TACRED数据集的特点是类别更多、样本量更大、句子往往更长,而且实体类型信息非常丰富。迁移过去时,把实体类型文本也作为标记加入输入往往有明显收益,但要注意输入长度限制。
DocRED是文档级关系抽取,一个文档里有多个句子,关系可能跨句存在。这时单纯的单句编码就不太够用了。常见做法是把整个文档编码后,再用跨句的注意力机制去抽取实体对之间的证据。我们这套结构可以作为基础编码器,再在上面接一个跨句聚合层。
还有一类远程监督自动构造的数据集,噪声非常大。迁移时最关键的是去噪策略,比如多实例学习或者confidence-based样本筛选,而不是单纯改进分类器结构。
5. 后续还能往哪里走
5.1 从判别式到生成式
聊点更大的趋势。现在围绕预训练模型做关系抽取,方向上出现了一个明显的分化:一类是以我们上面讨论为代表的判别式分类方案,另一类是把RE当作生成任务来做。
生成式的思路是,把关系三元组直接写成文本序列,让模型从输入句子生成这段序列。比如从“The report was written by John”生成“Product-Producer(report, John)”。这样做的好处是形式灵活,可以处理多个关系同时存在的情况,也方便和端到端的信息抽取方案整合。缺点是可解释性稍差,而且生成目标里需要预定义一个比较好的输出模板,否则模型容易走偏。
不过即便到了LLM时代,判别式方案也没有过时。原因在于:关系抽取在很多场景里要求稳定、可控、可解释,判别式分类头能让你清楚知道模型依据哪些特征做出了判断;而且在小样本和低资源场景下,判别式效果通常比直接让生成模型爆发式地猜更快更稳。
5.2 给打算冲SOTA的同行几句建议
如果你也想在这类数据集上刷新成绩,我的经验可以浓缩成几句话:先把最朴素的baseline完整跑通,再一步步往上加东西,每加一个模块都要用消融实验确认收益。不要一上来就追求花哨的结构,也不要在没有确认过拟合程度的情况下盲目堆参数量。
先加实体标记,再做实体感知池化,然后调分类头,最后上训练tricks,这条路径每一步都是稳定的、可预期的。等你跑完这一套流程,对关系抽取这个任务的理解深度,绝对比直接拿一个最先进的大模型黑盒跑一遍要扎实得多。
最后再分享一个小技巧:做消融实验时一定保持所有超参数不变,只改一个变量。我见过很多人调着调着把学习率和batch size一起改了,结果涨了分都不知道是哪个模块的功劳,后面想复现都困难。实验纪律有时候比模型设计本身更能决定项目能不能持续往前推进。