首页 / 资讯中心 / 文章详情

关系抽取实战:TACRED上基于BiLSTM与位置感知注意力的模型复现

关系抽取实战:TACRED上基于BiLSTM与位置感知注意力的模型复现 ★ FEATURED ARTICLE
简介位置感知注意力模型在关系提取任务中用于捕捉实体间上下文关联这份PyTorch实现面向自然语言处理与信息抽取方向的开发者围绕TACRED数据集提供完整训练与评估流程。压缩包共21个文件约41KB主体为12个Python代码文件覆盖数据加载、模型层搭建、RNN模块、训练与评测逻辑另有3个JSON数据或配置、2个Markdown说明、2个shell脚本可配合脚本快速下载GloVe词向量并构建词汇表。目前已有845人学习浏览。通过该资源可了解位置感知注意力机制如何改进关系分类掌握从词汇准备、模型训练到评分输出的完整链路并借助项目脚本直接运行实验、调整超参数适合用于复现相关论文结果或作为关系抽取任务的基础实现参考。1. tacred-relation 这条主线关系抽取为什么值得自己复现拿到一批合同、病例或者裁判文书你想让程序回答「A 和 B 之间是什么关系」这就是关系抽取。tacred-relation这个项目标题指向的是一类非常经典的落地范式不依赖预训练大模型用 LSTM 加位置感知注意力在 TACRED 这样的大型关系抽取语料上训练一个能直接拿去标注新数据的模型。这个方案最打动人的地方在于两三百行 PyTorch 就能跑通显存需求低而且每一步判断都有迹可循不是黑匣子。我第一次复现这个模型时最大的意外是去掉位置感知注意力F1 掉了一大截。就这么一个不起眼的「把实体相对位置拼进 Embedding」的细节效果超过了很多花哨的结构。这篇文章会把数据准备、模型拆解、训练和踩坑完整过一遍新手能照做熟手可以直接按我给的参数边界去调。2. 先读懂 TACRED任务定义、数据分布与位置感知的前提2.1 TACRED 数据长什么样字段、关系类型与不均衡分布TACRED 是从知识库填充评测任务衍生出来的监督关系抽取数据集标注单位是句子级别。每条样本包含一个句子、句子里出现的一对实体以及这对实体在句子中体现出的关系标签。整个数据集覆盖上百种关系类型其中专门有一类no_relation表示句子里的两个实体没有构成任何目标关系。我第一次拿到数据时最直观的感受是no_relation样本占了大头。这意味着哪怕模型什么都不学只输出no_relation也能拿到一个不低的准确率基线。但评测用的是 micro-F1不是准确率所以「无脑预测多数类」并不会帮你拿高分反而会让模型在少数关系类别上的召回几乎为零。数据划分上TACRED 提供了标准的训练、开发、测试三个子集字段大致如下字段内容用途token已经分好词的句子序列模型输入subj_start / subj_end头实体在 token 序列中的起止位置计算相对位置、实体标记obj_start / obj_end尾实体位置同上subj_type / obj_type实体类型标签比如人物、组织、地点额外特征relation关系标签训练目标这里一个容易被忽略的设计是「实体位置」本身就在字段里不需要你重新做命名实体识别。数据集的句子都已经做过分词token 序列直接可用。你唯一要处理的是把这些位置信息变成模型能吃的东西。2.2 实体位置之外的隐藏信号实体类型与句法线索TACRED 的标注里还有一个容易被低估的信息实体类型。比如subj_typePERSON、obj_typeORGANIZATION这种类型信息在预测「任职」「创始人」这类关系时几乎起决定作用。常见做法是把类型字符串做成一个小 Embedding追加到 token 表示后面。除了实体类型句子本身还携带句法线索。比如「A 出生于 B」中的「出生于」天然提示出生关系而位置感知注意力要做的就是让模型学会把这些线索和实体位置对齐。早期工作里也尝试过加入依存句法树特征但实现复杂度明显上升收益在 TACRED 上并不稳定。我的建议是第一步先只做 token 位置 类型跑通基线后再考虑句法增强。2.3 位置感知要解决的问题实体与上下文的对齐难题没有位置信息时BiLSTM 对句子编码的是一整个序列的语义。模型知道「某个人」在句子里但不知道这个人具体是哪个 token尤其是句子较长、实体隐藏在从句里时隐藏状态的最后一位根本无法保留清晰的实体边界。位置感知的做法很直接对句子里的每个 token计算它到头实体的相对距离再计算到尾实体的相对距离把这两个距离映射成向量拼到 token 的 Embedding 上。这样每一个位置上的隐藏状态都携带了「我离两个实体多远」的信息注意力机制才能准确地从上下文里挑出和实体相关的词。这一步是整个模型的核心。没有它模型像蒙着眼在句子里摸有了它模型至少知道目标在哪。后面第 4 章的代码里你会看到位置信息只是两个整数但就是这两个整数让 F1 产生质变。3. 位置感知注意力模型的结构拆解3.1 相对位置窗口与位置嵌入的维度选择位置感知的第一个设计决策是距离要截断到多少。句子长度动辄几十甚至上百如果每个距离都单独成一个索引嵌入表会很大而且尾部距离几乎不会出现几次学不好。常见做法是把相对距离截断到一个窗口内比如 [-60, 60]超出窗口的都归到边界。位置嵌入维度一般选 25 到 100 之间。太小表示能力不足太大在数据量不够时容易过拟合。我在 TACRED 上常用的配置是每个实体位置维度 50两个实体拼接后是 100。这个量级和 token Embedding 拼在一起不会喧宾夺主也能让模型感知到精确距离。# 计算相对位置并映射到截断窗口 def compute_position_ids(token_len, subj_start, subj_end, obj_start, obj_end, max_window60): pos_ids [] for i in range(token_len): # 使用实体起始位置作为锚点超出窗口的截断到边界 d1 i - subj_start d2 i - obj_start d1 max(-max_window, min(max_window, d1)) d2 max(-max_window, min(max_window, d2)) pos_ids.append((d1 max_window, d2 max_window)) return pos_ids这里有个细节值得注意很多实现用的是实体起始位置而非实体中心位置。如果实体很长且关键修饰词在实体内部起始点可能错过。我一般先按起始点做如果实体平均长度偏长超过 3 个 token再改成中心位置重新试一轮两者在验证集上的差异可以作为选型依据。3.2 BiLSTM 编码与注意力融合的工作流程模型主体是标准的 BiLSTM 编码器。token 序列经过 Embedding 层拿到词向量位置索引经过位置嵌入层拿到位置向量两者拼接后输入 BiLSTM。前向和后向的隐藏状态拼接得到包含上下文信息的序列表示。此时序列里的每一个向量都在说「我懂这个词且我知道它离实体多远」。接下来要做的是把序列压缩成一个句子向量。这里不能用普通的平均池化因为不同词对关系的贡献差别很大。常见做法是加一层注意力为每个位置的向量学习一个权重重点突出与实体关联紧密的词。注意力有一个关键实现细节查询向量可以直接用头实体和尾实体的平均表示来构造而不是用一个随机初始化的全局向量。因为关系判断的基准是这两个实体注意力应该围绕它们展开。我在实验里发现用实体表示引导注意力比全局可学习向量在 TACRED 上普遍高 1 到 2 个 F1 点。3.3 位置感知方案和其他关系抽取模型的对比跑 TACRED 时你会接触到几类方法放在同一张表里看会更清楚方法类型核心思想优势劣势CNN 系列卷积捕捉 n-gram 特征训练快、简单长距离依赖弱GCN 系列在依存树上做图卷积利用句法结构需要解析器、跨领域不稳定位置感知注意力显式编码相对距离实现简单、可解释、稳定上限不如预训练模型预训练模型微调整个 Transformer 微调效果最高显存开销大、推理慢、可解释差这张表的结论很直接如果资源紧张或数据来自垂直领域位置感知注意力模型是最稳的起点。它不依赖外部解析工具也没有大模型的部署成本。把它跑通后你甚至可以把它当作预训练模型的基线用来衡量加一个重型编码器到底值不值。4. PyTorch 实现从 token 到 relation 的完整通路4.1 数据预处理把 JSON 转成训练样本TACRED 原始数据是 JSON 格式每条样本里的 token 是已经分好的词。预处理的第一步是把 token 转成索引同时保留实体类型和相对位置。这里我习惯把实体 token 做一次特殊标记比如在实体前后插入特殊的边界符同时保留原有的位置数组两者互补。import json import torch from torch.utils.data import Dataset class TACREDDataset(Dataset): def __init__(self, path, vocab, label2id, max_window60): self.data [] with open(path, r, encodingutf-8) as f: for line in f: item json.loads(line.strip()) tokens item[token] subj_start, subj_end item[subj_start], item[subj_end] obj_start, obj_end item[obj_start], item[obj_end] # 拿到 token 对应的 id 序列 token_ids [vocab.get(t, vocab[[UNK]]) for t in tokens] # 计算相对位置对 pos1, pos2 self._compute_position_ids( len(tokens), subj_start, obj_start, max_window) label label2id[item[relation]] self.data.append({ token_ids: token_ids, pos1: pos1, pos2: pos2, subject_type: item[subj_type], object_type: item[obj_type], label: label })代码里_compute_position_ids做的事和 3.1 节一致只是改成了返回两个等长序列pos1和pos2分别表示每个 token 到两个实体起始点的截断距离。数据类型上pos1和pos2会被当作索引送入位置嵌入层所以它们的取值范围必须落在位置嵌入表的大小范围内。预处理里最容易出错的是错位token 序列是分好词的实体起止位置是词下标不能拿字符下标去套。如果发现验证集损失降不下去先检查是不是位置对齐出了问题。4.2 模型定义位置嵌入、BiLSTM 与注意力分类头模型主体包含三个部分token Embedding 和位置嵌入在前BiLSTM 居中注意力池化和分类头在后。一个容易踩坑的地方是 dropout 的位置我习惯在 Embedding 输出、BiLSTM 输出、注意力权重后的特征向量上各放一次训练时过拟合会明显变慢。import torch.nn as nn import torch.nn.functional as F class PositionAwareAttention(nn.Module): def __init__(self, hidden_size, pos_dim, num_classes, token_vocab_size, type_vocab_size, pos_vocab_size121): super().__init__() self.token_embed nn.Embedding(token_vocab_size, 300, padding_idx0) self.pos1_embed nn.Embedding(pos_vocab_size, pos_dim) self.pos2_embed nn.Embedding(pos_vocab_size, pos_dim) self.type_embed nn.Embedding(type_vocab_size, 50) self.lstm nn.LSTM( input_size300 pos_dim * 2 50, hidden_sizehidden_size, num_layers2, batch_firstTrue, bidirectionalTrue, dropout0.5 ) self.attn_query nn.Linear(hidden_size * 2, hidden_size * 2) self.attn_weight nn.Linear(hidden_size * 2, 1) self.classifier nn.Linear(hidden_size * 2, num_classes) self.dropout nn.Dropout(0.5) def forward(self, token_ids, pos1, pos2, type_ids): # 得到每个 token 的基础表示 token_vec self.token_embed(token_ids) pos1_vec self.pos1_embed(pos1) pos2_vec self.pos2_embed(pos2) type_vec self.type_embed(type_ids) # 拼接出完整的输入表示 emb torch.cat([token_vec, pos1_vec, pos2_vec, type_vec], dim-1) emb self.dropout(emb) lstm_out, _ self.lstm(emb) lstm_out self.dropout(lstm_out) # 用注意力将序列压缩成句子向量 query self.attn_query(lstm_out) scores self.attn_weight(torch.tanh(query)).squeeze(-1) # 对 padding 部分做掩码防止注意力分配到无效位置 mask (token_ids ! 0).float() scores scores.masked_fill(mask 0, -1e9) attn_weights F.softmax(scores, dim1) sent_vec torch.bmm(attn_weights.unsqueeze(1), lstm_out).squeeze(1) return self.classifier(self.dropout(sent_vec))参数层面pos_vocab_size是位置窗口的两倍加一即max_window * 2 1这正好覆盖从-max_window到max_window的所有距离索引。hidden_size我一般取 200LSTM 层数取 2再往上收益很小训练时间却线性增长。type_embed是可选的如果实体类型字段不稳定可以去掉但 TACRED 上实体类型很全建议保留。4.3 训练循环交叉熵损失、梯度裁剪与 micro-F1训练循环里最值得关注的不是 loss 本身而是梯度裁剪和学习率的关系。双向 LSTM 在长句上很容易出现梯度爆炸不裁剪的话训练前期 F1 会在 10 到 50 之间剧烈抖动。学习率用 1e-3 配梯度裁剪范数 1.0 是稳定的起点如果用 Transformer 类的优化器设置反而容易在 LSTM 上失效。def train(model, loader, optimizer, device): model.train() total_loss 0 for batch in loader: token_ids batch[token_ids].to(device) pos1 batch[pos1].to(device) pos2 batch[pos2].to(device) type_ids batch[type_ids].to(device) labels batch[label].to(device) logits model(token_ids, pos1, pos2, type_ids) loss F.cross_entropy(logits, labels) optimizer.zero_grad() loss.backward() # 裁剪梯度防止长序列训练不稳定 torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) optimizer.step() total_loss loss.item() return total_loss / len(loader)评测时不要直接拿准确率说话要把每个类别的精确率和召回率都算一遍用 micro-F1 作为统一指标。TACRED 上no_relation占比特别高准确率很容易虚高甚至到 90% 以上但模型可能什么都没学到。def evaluate(model, loader, device): model.eval() preds, trues [], [] with torch.no_grad(): for batch in loader: logits model( batch[token_ids].to(device), batch[pos1].to(device), batch[pos2].to(device), batch[type_ids].to(device) ) pred logits.argmax(dim-1).cpu().tolist() preds.extend(pred) trues.extend(batch[label].tolist()) return f1_score(trues, preds, averagemicro, zero_division0)到这里训练和评测的最小通路已经完整。由于 TACRED 原始数据格式在不同渠道拿到的版本略有差异我的经验是先写一个统计脚本打印训练集里关系类别的分布和样本数量确认数据加载没有问题再开始训练省下大量排查时间。5. 训练中的常见坑TACRED 与位置模型的血泪经验5.1 开发集 F1 虚高测试集却翻车现象验证集上 F1 到了 70 以上测试集一跑只有 55 左右差距不是几个点而是十几个点。原因TACRED 存在训练集和测试集句子相似度高的问题。模型很容易在验证阶段把同主题句式的「词面特征」记住换一批文本就失效。另一个可能原因是调参时对着验证集做了太多次选择过拟合到了验证集上。解决训练过程中每个 epoch 都用验证集打分但保存模型权重时不要只保存最高分那一版。多保存最后三个 epoch 的权重测试时逐个跑一遍取中间水平的结果作为最终口径。如果三个版本波动超过 5 个点说明模型不稳定优先调 dropout 和 LSTM 层数。5.2 位置窗口截断值不合适长实体场景失效现象训练损失正常下降但你抽几个长句子人工检查发现实体尾巴上的关键修饰词完全没被模型关注。原因位置窗口截断到 60而实体本身有十几个 token计算相对位置时从实体起始点算起尾部 token 的距离已经超出窗口全部被截断成了同一个边界值等于丧失了距离信息。解决把位置计算的锚点从实体起始点改成实体中心点同时把窗口从 60 提到 100。这样做之后长实体内部的 token 依然有区分度。代价是位置嵌入表变大一点但模型参数增加的量级可以忽略。5.3 训练损失震荡F1 在 40 到 60 之间反复横跳现象每个 epoch 的 loss 都在下降但 F1 剧烈抖动感觉模型在「学一种本领忘一种本领」。原因学习率太高模型在损失曲面里来回穿越。BiLSTM 的梯度本身比较敏感加上注意力权重是动态计算的小范围内参数变化就足以改变整个句子的表示。解决把学习率从 1e-3 降到 5e-4同时把梯度裁剪从 1.0 收紧到 0.5。如果还抖检查是不是 embedding 层没有做归一化初始化nn.Embedding默认初始化方差偏大换成xavier_uniform_初始化一套重跑一次通常会稳定下来。5.4 实体类型特征没对齐关系预测存在系统性偏差现象人名类关系比如「出生于」「居住」还好组织类关系「成立者」「子公司」一塌糊涂预测结果总是指向相邻的其它类别。原因实体类型 Embedding 的映射表和数据集里的实体类型字符串没有完全对齐。比如数据集里写成PERSON你的类型表里写成了person导致绝大多数样本的类型 ID 落到了[UNK]上类型特征等于没用。解决数据加载时先对全部实体类型做一次去重和映射检查打印出类型列表确认数据里实际出现的类型和你构建的词表完全一致。这个检查一分钟就能做完但能省掉一整个下午的猜测时间。5.5 随机种子换来换去结果忽高忽低现象同样的代码和参数换了随机种子跑F1 从 62 到 68 都能出现无法判断改动是有效还是运气。原因LSTM 的初始化、dropout 的掩码、注意力权重的初始化都受随机种子影响。TACRED 的样本量不算巨大模型容量不小方差自然大。解决每个实验至少跑三个种子取平均数和标准差作为最终报告口径。如果你想快速感知一次改动的好坏可以固定一个种子但确认结论前必须跑多种子验证。我曾因为只看单次结果把一个无效改动当成了有效改进白白浪费了一周。6. 进阶验收可视化注意力权重定位模型行为训练结束后不要只看指标。我自己习惯做一步「模型审问」从验证集里挑出预测正确和预测错误各十条样本把注意力权重可视化出来看模型到底在关注哪些词。这一步在 PyTorch 里实现很直接只需要在 forward 里把attn_weights暴露出来。我见过大量的所谓「效果差」根本不是模型结构问题而是数据预处理错位。注意力可视化能最快地暴露这类问题如果模型把注意力放在一堆无关的介词上说明位置信号没有起作用如果注意力集中到实体类型附近说明模型学会了依赖实体背景信息。这两种情况对应完全不同的修正方向一个改位置编码一个改数据。如果做完了可视化、调完了参数模型在你的垂直领域语料上仍然达不到满意效果我的最后一条建议是回到数据标注上挑一些错误样本分析关系类型是否互相混淆比如「出生于」和「居住于」在语料里本身就边界模糊。没有完美的模型但有明确的改进路径这也是位置感知模型最让人放心的地方——它不隐藏决策依据。做这个方向的开发者有一个共同习惯不迷信新鲜模型先在 TACRED 上跑通简易高效的传统基线再逐步加复杂度。这个习惯帮我少走了很多弯路。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站