
简介TACRED关系提取任务的位置感知注意力RNN模型PyTorch实现旨在为自然语言处理和信息抽取领域的研究者、开发人员以及学生提供一份可直接运行的基线代码。资源完整覆盖从数据准备到训练评估的各个模块包括词向量下载与解压脚本、词汇表构建、初始向量生成、数据加载封装、模型层与循环网络定义、训练及评估流程能够帮助使用者在熟悉TACRED数据集的基础上快速复现论文方法并替换自有组件。压缩包共包含21个文件以12个Python脚本为核心另有3个JSON参数配置、2个Shell辅助脚本以及说明文档与许可证文件整体体积仅41KB结构紧凑、便于阅读。目前已有845人学习阅读适合已经掌握Python与PyTorch基础、想深入理解位置感知注意力建模或拓展关系分类实践的读者。该实现通过在实体对周围加入相对位置编码与注意力融合机制并叠加双向RNN编码有效捕捉句中关键线索通过这份代码还可完成从数据预处理到模型评价的完整工程链路对科研复现和实际工程项目均具参考价值。1. 用 PyTorch 复现 TACRED 关系提取为什么位置感知注意力比普通注意力更值得做关系提取任务里TACRED 是绕不开的基准数据集而位置感知注意力模型是能在 TACRED 上稳定拿到高 F1 的一类经典方案。很多团队在关系抽取上先用 BERT 做编码器结果发现模型对实体对的相对位置完全不敏感——句子里的“张三”和“李四”换了个位置预测结果就乱了。位置感知注意力做的事情很简单在计算注意力权重时把每个 token 到两个实体的相对距离编码成向量拼进去让模型知道哪些词离实体更近、哪些词才是判断关系的关键证据。这篇笔记面向想复现该模型的 NLP 工程师和学生我会从 TACRED 数据格式、位置嵌入构造、PyTorch 模型搭建到训练踩坑一路讲完照着跑就能得到一个可用的基线。2. TACRED 任务与位置感知注意力的设计逻辑先搞懂数据长什么样、注意力为什么需要“位置”2.1 TACRED 任务定义句子 实体对 → 关系类别TACRED 每个样本由一句话和句子中标注出的一对实体构成目标是预测这两个实体在句子语境下是什么关系。比如句子“某公司的创始人张三昨天接受了媒体采访”实体对是“某公司”和“张三”模型要输出“创始人”这类关系标签。数据集中共有 41 种关系类型再加上一个占比极高的 no_relation无关系类实际分类是 42 类。原始 JSON 样本的结构大致是tokens 是分好词的列表subj_start / subj_end 和 obj_start / obj_end 是主宾实体在 tokens 中的起止下标relation 是标签stanford_ner 和 stanford_pos 是辅助标注。信息抽取阶段只需要这六个字段就够了其余标注在基线模型中多数时候用不上。这里有个工程上的细节subj_end 和 obj_end 都是闭区间下标处理边界时容易出 bug后面数据准备章节会专门演示怎么处理。从落地角度看TACRED 的难点不在数据规模而在类别极不平衡——约三分之二的样本都是 no_relation。如果模型无脑全部预测 no_relationmicro-F1 会很难看所以评估指标统一用排除 no_relation 后的 micro-F1这也是复现时最容易算错的地方。2.2 普通注意力丢了什么实体相对位置才是关系判断的关键线索句子编码后普通 self-attention 或 LSTMattention 只依赖 token 的语义表示去算权重。但在关系提取里“距离实体越近的 token 越重要”这一先验非常强。例如“某公司创始人张三”中“创始人”夹在两个实体之间距离实体 1 和实体 2 都很近它才是决定关系的核心词。普通注意力模型在长句子里容易把权重分给远处的主语修饰语因为那些词在语义上同样显著。位置感知注意力的做法是给每个 token 额外算两个数值它到实体 1 的最近距离、到实体 2 的最近距离。然后把两个距离映射成嵌入向量在 attention score 计算时拼接到 token 表示上。这样打分函数变成了 score f(h_i ⊕ p_i^subj ⊕ p_i^obj)其中 p 就是位置嵌入。模型在聚合句子信息时天然会优先参考实体附近的词即使实体在句子尾部注意力也能靠位置信号定位过去。我一般建议先复现 LSTM 位置感知注意力原因有三个训练速度快、显存占用低、调参空间清晰。BERT 编码器虽然上限高但位置感知这个设计在 BERT 上改动成本更大而且小数据集上 BERT 的收益不稳定。用 TACRED 这种规模的数据集LSTM 基线跑到 65-70 的 F1 是完全可能的足够给后续模型做对比。3. 从 TACRED 原始 JSON 到训练样本数据解析与位置嵌入的完整实现3.1 解析 TACRED JSON 并构造统一样本结构先把原始 JSON 读进来提取 tokens、实体下标和标签同时过滤掉实体下标异常的样本。下面是读取和解析的核心代码。import json import torch from torch.utils.data import Dataset def load_tacred_raw(path): with open(path, r, encodingutf-8) as f: samples json.load(f) return samples class TACREDDataset(Dataset): def __init__(self, raw_samples, word2idx, label2idx, max_dist100): self.data [] for s in raw_samples: tokens s[tokens] subj_start, subj_end s[subj_start], s[subj_end] obj_start, obj_end s[obj_start], s[obj_end] # TACRED下标是闭区间转成左闭右开会更好处理 subj_end 1 obj_end 1 if subj_start 0 or obj_start 0: continue token_ids [word2idx.get(w, word2idx[UNK]) for w in tokens] pos1 compute_position_ids(len(tokens), subj_start, subj_end, max_dist) pos2 compute_position_ids(len(tokens), obj_start, obj_end, max_dist) label label2idx[s[relation]] self.data.append((token_ids, pos1, pos2, label)) def __len__(self): return len(self.data) def __getitem__(self, idx): token_ids, pos1, pos2, label self.data[idx] return (torch.tensor(token_ids, dtypetorch.long), torch.tensor(pos1, dtypetorch.long), torch.tensor(pos2, dtypetorch.long), torch.tensor(label, dtypetorch.long))这段代码有两个容易写错的地方。第一TACRED 原始标注的 subj_end 和 obj_end 是闭区间直接拿去算位置会少覆盖一个 token所以要 1 转成左闭右开。第二word2idx 里一定要有UNK兜底否则验证集里出现训练集没见过的词会直接 KeyError 崩溃。label 到 id 的映射要基于训练集单独构建验证集和测试集沿用同一份映射避免数据泄露。3.2 相对位置编码截断距离、方向符号与嵌入表设计位置感知注意力的核心输入是每个 token 到两个实体的相对距离。距离计算规则是token 在实体内部时距离为 0在实体左侧时为负数绝对值等于到实体左边界的距离在实体右侧时为正数等于到实体右边界的距离。为了让距离值落在有限范围内需要把距离截断到 [-max_dist, max_dist]。def distance_to_entity(i, start, end): if i start: return i - start if i end: return i - end return 0 def compute_position_ids(seq_len, ent_start, ent_end, max_dist): pos [] for i in range(seq_len): d distance_to_entity(i, ent_start, ent_end) offset min(max(d, -max_dist), max_dist) pos.append(offset max_dist) # 平移到非负索引 return pos位置索引平移到非负后嵌入表大小就是 2 * max_dist 1。max_dist 通常取 100表大小为 201嵌入维度建议取 25 或 50。取值太小会丢距离信息太大则训练参数变多、在小数据集上容易过拟合。这里我用的是“先算距离、再查嵌入表”的经典做法PyTorch 的 nn.Embedding 直接接收索引序列不需要手工做 one-hot。有一点需要提醒位置嵌入是相对位置而非绝对位置所以同一个 token 在不同句子里即使绝对下标不同只要相对距离相同位置嵌入就是一样的。这是位置感知模型能泛化到任意长度句子的根本原因。如果误把绝对下标当距离送入嵌入表模型在长句上会直接失效。4. 用 PyTorch 搭建位置感知注意力模型从 LSTM 编码器到注意力聚合的完整前向过程4.1 模型整体结构与位置感知注意力模块实现模型分为四层词嵌入层、位置嵌入层两个独立的嵌入表、双向 LSTM 编码器、位置感知注意力池化层最后接一个线性分类器。词向量我一般用预训练 GloVe 初始化位置嵌入和 LSTM 参数随机初始化。下面先给出注意力模块。import torch import torch.nn as nn import torch.nn.functional as F class PositionAwareAttention(nn.Module): def __init__(self, input_dim, pos_dim, dropout0.1): super().__init__() self.score nn.Linear(input_dim pos_dim * 2, 1, biasFalse) self.dropout nn.Dropout(dropout) def forward(self, lstm_output, pos_emb1, pos_emb2, mask): # lstm_output: (batch, seq_len, hidden_dim) batch_size, seq_len, _ lstm_output.size() combined torch.cat([lstm_output, pos_emb1, pos_emb2], dim-1) # 拼接后维度为 hidden_dim 2 * pos_dim scores self.score(combined).squeeze(-1) scores scores.masked_fill(mask 0, -1e9) attn_weights F.softmax(scores, dim-1) attn_weights attn_weights.masked_fill(mask 0, 0.0) context torch.bmm(attn_weights.unsqueeze(1), lstm_output).squeeze(1) return context, attn_weights注意力打分的维度是 LSTM 输出维度拼接两个位置嵌入维度mask 在 softmax 前后各做一次。softmax 前 mask 是为了让 padding 位置不参与归一化softmax 后再补一次 0 是为了让加权求和时 padding 位置不贡献向量。两次 mask 都很关键漏一次模型就会在 padding 上积累噪音。4.2 完整模型前向嵌入层、LSTM 与分类头的拼接完整模型类把四个子模块串起来。LSTM 设置为双向hidden_dim200 时实际输出维度是 400注意力模块的输入维度要按 400 设置。分类器输入取 attention 池化后的上下文向量与 LSTM 输出维度一致。class TACREDRelationModel(nn.Module): def __init__(self, vocab_size, emb_dim300, pos_dim25, hidden_dim200, num_classes42, max_dist100, dropout0.5): super().__init__() self.word_embed nn.Embedding(vocab_size, emb_dim, padding_idx0) self.pos_embed1 nn.Embedding(2 * max_dist 1, pos_dim) self.pos_embed2 nn.Embedding(2 * max_dist 1, pos_dim) self.encoder nn.LSTM(emb_dim, hidden_dim, bidirectionalTrue, batch_firstTrue) self.attention PositionAwareAttention(hidden_dim * 2, pos_dim, dropout0.1) self.dropout nn.Dropout(dropout) self.classifier nn.Linear(hidden_dim * 2, num_classes) def forward(self, token_ids, pos1, pos2, mask): # mask: (batch, seq_len)1 表示真实 token0 表示 padding word_emb self.word_embed(token_ids) pos_emb1 self.pos_embed1(pos1) pos_emb2 self.pos_embed2(pos2) enc_out, _ self.encoder(word_emb) context, attn self.attention(enc_out, pos_emb1, pos_emb2, mask) logits self.classifier(self.dropout(context)) return logits, attnpadding_idx0 的作用是让PAD对应的词嵌入向量恒为 0配合 LSTM 和 mask 能减少 padding 对序列建模的干扰。LSTM 的初始 hidden state 我直接用了默认的全零初始化没有额外调参。分类器前加 dropout 是防止 attention 池化后的向量过拟合TACRED 这类中等规模数据集 dropout 取值 0.5 比较合适。完整模型还有几个变体值得尝试在拼接位置嵌入之前把位置嵌入加到 LSTM 输入侧而非注意力侧或者把句子按两个实体切成三段分别做池化再拼接。前一种实现简单但位置信息会被 LSTM 的递归逐步稀释后一种信息更直接但丢失了注意力权重的可解释性。我推荐先做论文里最常见的注意力侧拼接方案跑通后再对比变体效果。5. 训练与验证三个必调参数和五个高频踩坑记录5.1 训练流程类别加权损失、micro-F1 评估与随机种子训练循环本身不复杂难点在损失函数和评估口径。TACRED 的 no_relation 占比太高直接用交叉熵会让模型偏向多数类。from sklearn.metrics import f1_score def build_class_weight(label_counts, num_classes): total sum(label_counts.values()) weights torch.zeros(num_classes) for idx in range(num_classes): cnt label_counts.get(idx, 1) weights[idx] total / (num_classes * cnt) return weights def evaluate(model, loader, device): model.eval() preds_all, labels_all [], [] with torch.no_grad(): for batch in loader: token_ids, pos1, pos2, labels [x.to(device) for x in batch] mask (token_ids ! 0).long() logits, _ model(token_ids, pos1, pos2, mask) preds logits.argmax(dim-1) preds_all.extend(preds.cpu().tolist()) labels_all.extend(labels.cpu().tolist()) # 排除 no_relationid 为 0后计算 micro-F1 mask_eval [l ! 0 for l in labels_all] preds_filtered [p for p, m in zip(preds_all, mask_eval) if m] labels_filtered [l for l, m in zip(labels_all, mask_eval) if m] return f1_score(labels_filtered, preds_filtered, averagemicro)类别权重的计算方法是 total / (num_classes * cnt)让每个类别的权重之和在数量上更均衡。评估时把 no_relation 样本全部过滤掉再算 F1这符合 TACRED 的官方口径。如果不过滤模型预测对大量 no_relation 样本会虚高准确率F1 失去参考意义。随机种子要在训练脚本最开头设置三次Python random、NumPy、PyTorch再加 torch.backends.cudnn.deterministic 确保 GPU 卷积和 LSTM 的运算结果可复现。我在多个项目里遇到过只设置 torch.manual_seed 但忘了设置 NumPy 的情况导致同一份代码跑两次 F1 差 2 个点以上。5.2 训练侧踩坑类别向量、Mask 与 Seed 三件套现象一训练 loss 稳定下降但每个 epoch 结束后验证 F1 都是 0。原因是模型把所有样本都预测成了 no_relation因为 no_relation 占比太高普通交叉熵的最优策略就是全预测多数类。解决方法是给损失函数传入类别权重或者对 no_relation 样本降采样。我实测类别加权更稳降采样会让模型在少类别上过拟合。现象二loss 在训练初期轻微下降后就卡住不动验证 F1 始终在低位徘徊。原因多半是 mask 没有正确传入注意力模块padding 位置参与了 softmax 归一化。典型错误是直接用 token0 判断 padding但忘记把 mask 从 token IDs 转为 long 类型。排查时可以打印一个 batch 的 attn_weights观察 padding 位置是否还有非零权重。现象三同一个训练脚本连续跑两次验证 F1 差异很大最好的一次和最差的一次能差 3 个点。原因是 Python random、NumPy random、PyTorch 的随机数生成器是独立的只固定 PyTorch 的 seed 不够。解决方法是三个 seed 一起固定同时设置 cudnn.deterministicTrue 和 cudnn.benchmarkFalse后者会牺牲少量训练速度换取完全可复现。5.3 结构细节上的三个坑位置距离方向、截断距离与词向量微调现象四模型训练正常loss 正常下降但最终 F1 只有 50 出头比不带位置感知的普通注意力还低。原因多半是位置距离方向写反了——实体左侧 token 的距离应该为负右侧应该为正如果写反注意力网络学到的是“远离实体的词更重要”的错误先验。排查方法是选几个样本手工打印 pos1 和 pos2检查实体前 token 的距离值是否小于实体后 token。现象五句子特别长时 F1 下降明显短句子上表现正常。原因是 max_dist 截断太小比如取 20距离超过 20 的 token 全部挤在同一个位置桶里模型无法区分远近。TACRED 里有些句子超过 50 个 token我推荐 max_dist 取 100嵌入表 201 项不会显著增加参数量又能保证长句尾部的 token 还有区分度。现象六预训练 GloVe 词向量不加处理直接整层微调loss 下降很快但验证 F1 波动剧烈。原因是 TACRED 训练集只有约 68000 句微调 300 维词向量容易过拟合。解决方法是嵌入层设置 freezeTrue 先跑几个 epoch等注意力层和分类器稳定后再解冻或者对嵌入层单独用更小的学习率通常是主学习率的 0.1 倍。6. 把位置感知注意力迁移到自己的数据上数据集格式改造与 BERT 编码器替换思路6.1 自定义三元组数据转 TACRED JSON 格式自己业务里拿到的数据通常是“句子 实体 1 实体 2 关系标签”的形式而模型需要的是实体在 tokens 里的下标。转换步骤是先用分词器把句子切词再在词序列中定位实体首尾位置。这里有一个经常翻车的点用 BERT 的分词器会把一个词拆成多个 subword实体起始和结束下标必须映射到 subword 层级否则位置信息全错。如果是英文数据我习惯直接用空格分词简单且不易错中文数据则先按字切分再定位实体下标这样位置计算最稳妥。6.2 将 LSTM 替换为 BERT 编码器时的位置感知适配如果决定把编码器换成 BERT位置感知注意力模块可以保留不变只需要把 lstm_output 替换为 BERT 的最后一层输出。注意 BERT 的输出维度是 768注意力模块的 input_dim 要改成 768BERT 自带 segment embedding 和位置编码此时不需要再接入我们自己构造的相对位置嵌入——BERT 的绝对位置编码不能替代实体相对距离信息所以位置感知注意力模块仍然建议保留。实践上还有一个更轻量的替代方案在 BERT 输出上只做实体附近的 token 池化取两实体区间内的 hidden state 做平均再拼接到 CLS 向量上分类效果接近完整位置感知注意力但代码更少。我现在的习惯是任何新数据集上先跑 LSTM 位置感知注意力作为基线确认数据格式和评估代码没问题后再替换 BERT。这个顺序能帮我快速分辨效果差异到底是来自模型还是来自数据预处理。这套流程我在几个内部项目上反复用过每次都是位置嵌入和 mask 的细节在拖后腿模型本身反而是最稳的部分。希望帮到你。本文还有配套的精品资源点击获取