简介:基于图卷积神经网络的实体关系抽取完整项目,面向深度学习与自然语言处理学习者、毕业设计/课程设计学生,解决从原始文本到实体关系分类的端到端流程。压缩包共41个文件、约93.32MB,主要包含17个Python源文件和10个pyc编译文件,覆盖数据预处理、模型构建、训练、测试、GUI交互等完整模块;另有5个文本说明、2个JSON配置、2个pt模型文件,以及PL评分脚本、NPY/PKL数据文件等,训练好的模型与全部数据均已打包,解压后按文档步骤即可复现。文档部分提供项目说明和部署教程,源码中封装了词表构建、树结构处理、常量配置、训练器等基础组件,结构清晰便于二次开发。目前已有151人学习下载。资源契合高校毕业设计、课程设计或期末项目需求,也适合希望掌握图卷积网络在关系抽取中实际应用的开发者对照源码逐步进阶。
1. 实体关系抽取为什么绕不开图卷积神经网络
先抛一个现象:同样是「乔布斯创立了苹果公司并担任首任 CEO」这个短句,抽 (乔布斯, 创始人, 苹果公司) 用 BiLSTM 就能行;可一旦把实体放到 40 字以上的长句里,序列模型很容易把中心词盯丢。实体关系抽取要做的,是把句子里的两个实体映射到预定义关系;图卷积神经网络(GCN)的切入角度很直接——先对句子做依赖解析,把语法结构当成一张图,再用图卷积层做邻接聚合。相比 LSTM 沿时间步线性传信息,GCN 能沿依赖树上的短路径把两个实体的信息直接拉近。这个标题对应的是一套完整链路:源码、部署教程文档、全部数据、训练好的模型,缺一环都跑不出分数。下面按链路展开,给出可运行代码和参数。
2. 图卷积网络的关系抽取建模:依赖树、邻接矩阵与消息传递
2.1 从句子到图:依赖解析树与自环邻接矩阵
先明确一点:图卷积网络不直接吃原始句子,它消费的是依赖树。以「乔布斯 创立 苹果 公司」这句话为例,依赖解析会给出一个以谓词为中心的树结构,root 是「创立」,nsubj 指向「乔布斯」,obl 指向「苹果」。树上的节点是每个词,边是词与词之间的语法依赖。这套结构天然就是一张无向图,转成邻接矩阵 A 只需两步:A[i][j] = 1 当且仅当词 i、词 j 之间有依赖边;再把对角线全部置 1,加上自环。
自环是必须的,否则 GCN 第 l 层做完邻居聚合之后,节点自身的向量完全没有参与第 l+1 层的输入,等于每过一层就丢一次自己的原始信息,层数一多特征就全被邻居「中和」了。依赖树的节点度数差异极大,根节点可能只有两条边,而并列结构、介词短语能连七八个节点,如果不做归一化,度数高的节点在聚合时会把邻居数量当成权重放大,特征数值随层数膨胀,训练很难收敛。
2.2 GCN 层的消息传递:公式、参数与维度变化
单层 GCN 的前向计算可以用下面这个式子概括:
H^(l+1) = ReLU( D̃^(-1/2) · Ã · D̃^(-1/2) · H^(l) · W^(l) )
其中 à 是加了自环的邻接矩阵,D̃ 是 à 的度矩阵(对角阵),W^(l) 是该层的线性变换参数,H^(l) 是第 l 层的节点特征矩阵。D̃^(-1/2)·Ã·D̃^(-1/2) 做的是对称归一化,它等价于把每个节点的邻居特征按「两端节点度数的乘积开根号」缩放,聚合结果不再受节点度数影响。下面是一个可以直接使用的 PyTorch 实现:
def normalize_adj_with_self_loop(adj): # adj: [batch, seq_len, seq_len],对角线为 0,尚未加自环 adj = adj.clone() bsz, seq_len, _ = adj.shape eye = torch.eye(seq_len, device=adj.device).unsqueeze(0) adj = adj + eye # 第一步:补自环,节点聚合时保留自身特征 deg = adj.sum(dim=-1) # 第二步:按行求和得到每个节点的度 deg_inv_sqrt = deg.pow(-0.5) deg_inv_sqrt[torch.isinf(deg_inv_sqrt)] = 0.0 d_inv_sqrt = torch.diag_embed(deg_inv_sqrt) return torch.bmm(torch.bmm(d_inv_sqrt, adj), d_inv_sqrt) # 第三步:对称归一化这段代码有三处要点。其一,补自环用广播加法,eye.unsqueeze(0) 变成 [1, L, L] 后直接加到 batch 的每一份邻接矩阵上;其二,deg.pow(-0.5) 之后要把 inf 置 0,避免除以 0 在反向传播时产生 NaN;其三,两次 bmm 才是 D̃^(-1/2)·Ã·D̃^(-1/2),如果只乘一次 D̃^(-1/2),结果会偏向低度节点。归一化之后,GCN 层的聚合退化成一次 batch 矩阵乘法,不用像实现 Message Passing 那样逐边遍历。
2.3 为什么要做依赖树剪枝:路径约束与多跳关系的取舍
整棵依赖树直接喂进图卷积网络,效果往往不如「剪枝后的子图」。GCN 做关系抽取的经典做法,是只保留两个实体节点在依赖树上的最短路径,再向外扩展 k 跳邻居。原因有两个:第一,句子里的修饰成分、插入语、状语从句和当前实体对的关系判断无关,它们会通过图卷积把噪声扩散到实体节点上;第二,两个实体在依赖树上可能隔着七八条边,GCN 需要很多层才能把信息传过去,而层数一多,梯度衰减、过平滑(所有节点特征趋同)两个问题一起出现。剪枝以后实体间距离通常缩小到 1~2 跳,两层 GCN 就够用。
最近邻缓冲带参数 k 一般取 1。k=0 意味着只保留路径主干,过于严格;k=2 会把大量无关邻居放进来,剪枝就失去意义。下面这个函数示意了路径剪枝加缓冲带的邻接矩阵重建逻辑:
def build_pruned_adj(dep_edges, e1, e2, max_len, k=1): # dep_edges: [(父节点索引, 子节点索引)] 依赖边列表 # e1, e2: 两个实体在句子 token 序列中的位置索引 graph = {i: set() for i in range(max_len)} for h, d in dep_edges: graph[h].add(d) graph[d].add(h) # 依赖树转无向图 path = bfs_shortest_path(graph, e1, e2) # 实体间最短路径上的节点集合 keep = set(path) for node in path: for nb in graph[node]: keep.add(nb) # 向外扩 1 跳,引入局部上下文 adj = np.zeros((max_len, max_len), dtype=np.float32) for i in keep: for j in graph[i]: if j in keep: adj[i][j] = 1.0 return adj注意 keep 集合只决定「哪些节点参与消息传递」,节点 i、j 之间有边才在矩阵里置 1。剪枝后邻接矩阵的稀疏度通常会降到原始依赖树的 30%~50%,配合稀疏矩阵存储,一次 epoch 的显存占用和训练时间都能明显下降。依赖树剪枝在预处理阶段离线完成,不要在训练循环里现算。
3. 源码实现拆解:PyTorch 版 BiLSTM-GCN 实体关系抽取模型
3.1 项目文件结构:拿到压缩包先核对这几项
「源码+部署教程文档+全部数据+训练好的模型」这类打包项目,目录结构大多相似,但拿到手先别急着跑 train.py,先按职责核对文件是否齐全,避免跑了一半才发现缺 checkpoint。我一般按这张表逐项确认:
| 目录/文件 | 职责 | 拿到手先确认的内容 |
|---|---|---|
| src/model.py | 模型定义 | 输入是 token ids 还是已编码向量 |
| src/train.py | 训练入口 | 读哪个配置文件、checkpoint 存到哪 |
| src/data_loader.py | 数据读取 | 支持 SemEval 还是 JSON 格式 |
| data/ | 原始与预处理数据 | train/dev/test 划分是否与文档一致 |
| checkpoints/ | 训练好的模型权重 | .pt/.pth 文件是否带 optimizer 状态 |
| docs/ | 部署教程文档 | 运行环境依赖版本是否与代码匹配 |
核对完再建环境安装依赖,我一般这样做:
conda create -n re-gcn python=3.8 -y pip install -r requirements.txt python src/train.py --config src/config/semeval.yaml第一条命令创建独立 Python 3.8 环境,避免和系统全局环境互相污染;第二条按 requirements.txt 安装全部依赖;第三条是训练入口,--config 指定数据集配置。常见的失败点是老项目用的 torch 版本和当前 numpy 版本 API 对不上,报错集中在 pack_padded_sequence 和 torch.onnx.export 这两处,先读 docs 里的环境要求一节,不要直接装进全局环境。
3.2 输入特征:词向量与双实体位置向量拼接
实体关系抽取模型的第一层输入,通常不是单纯的 token embedding,而是「词向量 + 相对实体位置向量」拼接。位置向量的做法是:对句子里的每个 token,分别计算它到实体 1 和实体 2 的相对距离,再做 embedding。以「乔布斯 创立 苹果 公司」为例,实体 1 是「乔布斯」(位置 0),实体 2 是「苹果」(位置 2),那么「创立」的相对位置就是 (1, -1),被映射成两个可学习的向量。
class RelationEmbedding(nn.Module): def __init__(self, vocab_size, word_dim=300, pos_dim=50, max_pos=60): super().__init__() self.max_pos = max_pos self.word_embed = nn.Embedding(vocab_size, word_dim, padding_idx=0) self.pos1_embed = nn.Embedding(max_pos * 2 + 1, pos_dim) self.pos2_embed = nn.Embedding(max_pos * 2 + 1, pos_dim) def forward(self, token_ids, pos1, pos2): # pos1/pos2: 相对位置偏移,范围 [-max_pos, max_pos],加 max_pos 平移成非负索引 w = self.word_embed(token_ids) # [B, L, 300] p1 = self.pos1_embed(pos1 + self.max_pos) # [B, L, 50] p2 = self.pos2_embed(pos2 + self.max_pos) # [B, L, 50] return torch.cat([w, p1, p2], dim=-1) # [B, L, 400]词向量维度 300、位置向量维度 50 是关系抽取里的常见配置,拼接后每个 token 的表示是 400 维。pos1/pos2 在预处理阶段就要算好,偏移超出 max_pos 的截断到边界,不要用随机值填充,否则模型会学到「异常距离」和「常见距离」之间的虚假关联。padding 位置的位置向量一般填 max_pos*2,也就是边界外一档,和所有真实距离区分开,语义上也符合「这个 token 离两个实体都特别远」。
3.3 BiLSTM 序列编码层
拿到拼接特征后,先用一层 BiLSTM 做序列编码,再喂给图卷积层。为什么中间要隔一层 BiLSTM,而不是直接把 word embedding 丢给 GCN?因为 word embedding 是静态的,不包含上下文;GCN 只看依赖树上相邻的词,如果两个实体在句法上距离远,GCN 需要很多层才能把信息传过去。BiLSTM 先把整句话的上下文压缩进每个位置,依赖树上的每个节点就都带着全局信息,GCN 的层数可以做得更浅。
self.encoder = nn.LSTM( input_size=word_dim + 2 * pos_dim, # 400 hidden_size=200, num_layers=1, batch_first=True, bidirectional=True, dropout=0.0 ) # 输出 [B, L, 400],等价于双向隐藏层拼接num_layers 通常取 1,加深 BiLSTM 对关系抽取的提升很小,却会把训练显存翻倍。hidden_size 取 200 时双向输出正好是 400 维,和输入维度一致,便于 GCN 层直接消费。dropout 在 LSTM 内部一般不开,放在 GCN 层之间和分类头之前更有效,这和使用预训练模型时「只开下游 dropout」的做法一致。
3.4 图卷积聚合层:邻接矩阵怎么参与前向计算
图卷积层的核心是「邻居特征的加权求和」,PyTorch 里直接用 bmm 把归一化邻接矩阵和特征矩阵乘起来:
class GCNLayer(nn.Module): def __init__(self, in_dim, out_dim, dropout=0.5): super().__init__() self.linear = nn.Linear(in_dim, out_dim, bias=False) self.dropout = nn.Dropout(dropout) def forward(self, h, adj_norm): # h: [B, L, in_dim];adj_norm: [B, L, L],对称归一化后的邻接矩阵 h_transform = self.linear(h) # [B, L, out_dim] out = torch.bmm(adj_norm, h_transform) # 邻居特征聚合 return F.relu(out)bmm 是 batch 矩阵乘法,把每个样本的 [L, L] 邻接矩阵和 [L, out_dim] 特征相乘,得到的每一行就是「按归一化权重加权求和后的邻居向量」。这里有两个容易写错的细节:第一,linear 的 bias 要置 False,因为 bias 会在聚合时被累加 num_neighbors 次,等于给每个节点叠加了一个与度数相关的常数偏移;第二,激活函数放聚合之后而不是之前,顺序错了会出现「激活→加权→再激活」的双重非线性,收敛会变慢。
整个模型的 forward 流程是:RelationEmbedding → BiLSTM → 多层 GCN(每层之间加 dropout 和残差连接)→ 取两个实体位置的向量 → 全连接分类头。图卷积网络通常堆 2 到 3 层,超过 3 层以后依赖树上的节点感受野重叠严重,收益是负的,调参时优先动层数而不是 hidden size。
3.5 关系分类头与损失函数
分类头一般有两种做法。一种是直接把两个实体位置的 GCN 输出拼接后过一个带 dropout 的 Linear;另一种是像 PCNN 那样把句子按实体切三段做 max-pooling 再拼接。对关系抽取任务,后者对「关系由实体周围局部短语决定」的场景更稳。损失函数用交叉熵即可,类别不平衡明显时给样本量小的关系类加大权重,权重按 1/log(1 + count) 设置比直接反概率更抗极端值。
class RelationGCN(nn.Module): def __init__(self, n_rel): super().__init__() self.fc = nn.Linear(400 * 2, n_rel) def forward(self, h, e1_idx, e2_idx): # h: [B, L, 400],最后一层图卷积输出 e1 = h.gather(1, e1_idx.unsqueeze(-1).expand(-1, -1, h.size(-1))).squeeze(1) e2 = h.gather(1, e2_idx.unsqueeze(-1).expand(-1, -1, h.size(-1))).squeeze(1) feat = torch.cat([e1, e2], dim=-1) # [B, 800] return self.fc(F.dropout(feat, p=0.5, training=self.training))gather 取实体向量时用的是模型输出的最后一层特征,而不是 BiLSTM 的中间层输出——最后一层图卷积已经把依赖路径的信息聚合到实体节点上了,取中间层等于把剪枝白做。e1_idx.shape 是 [B, 1],unsqueeze 后 expand 成 [B, 1, 400] 才能沿 dim=1 gather,这是按索引取向量最常见的写法。
4. 数据与训练调参:图卷积关系抽取模型的 F1 复现与提升要点
4.1 训练数据格式:SemEval 与远程监督数据的字段说明
关系抽取公开数据集里,SemEval 2010 Task 8 是图卷积网络类模型最常用的验证集,规模小、标注规范,单张消费级显卡几小时就能跑完。常见公开版本的原始语料是两行一组:第一行是带 、 标记的句子,第二行是关系类型,例示如下:
The <e1>author</e1> of <e2>the key to salvation</e2> wrote a book. Cause-Effect(e1,e2)数据里一共 19 个关系类别,去掉 Other 后实际评测 18 类。加载时先用正则把 ... 和 ... 的标签位置记下来,再把标签从句子里剥掉,实体在句中的索引就是后面计算相对位置向量的依据。除了 SemEval,远程监督数据集(如 NYT)一般是 JSON 或三列格式,字段通常是 sentence / head / tail / relation,处理逻辑一致,只是实体边界要从 JSON 里读。拿到打包数据后,先统计 train/dev/test 的句子长度分布、实体对数量和每个关系类别的样本数,再决定 max_len,而不是拍脑袋填 128。
4.2 预处理管道:ID 化、文本截断与邻接矩阵生成
预处理管道里最需要盯紧的是「邻接矩阵必须在同一套依赖解析结果上生成」。训练、验证、测试三个阶段必须用同一版本、同一参数的依赖解析器,否则训练时的语法结构和推理时不一致,F1 直接掉 3 到 5 个点。解析结果生成后,离线把邻接矩阵存成边列表或稀疏矩阵,不要在线解析,否则数据加载速度会被解析器拖垮。
def encode_example(tokens, vocab, e1_start, e2_start, max_len, max_pos=60): tokens = tokens[:max_len] token_ids = [vocab.get(t, vocab.unk_idx) for t in tokens] def shift(idx): # 相对位置截断到 [-max_pos, max_pos],再平移成非负索引 return min(max(idx, -max_pos), max_pos) + max_pos pos1 = [shift(i - e1_start) for i in range(len(tokens))] pos2 = [shift(i - e2_start) for i in range(len(tokens))] # 补 padding:token 用 pad_idx,位置向量用 max_pos*2 这一档 while len(token_ids) < max_len: token_ids.append(vocab.pad_idx) pos1.append(max_pos * 2) pos2.append(max_pos * 2) return token_ids, pos1, pos2shift 函数里的 +max_pos 是把负偏移变成 Embedding 能查的非负索引。padding 位置的位置向量填 max_pos*2 而不是 0,因为 0 对应「恰好距离为 -60」的真实语义,混用会让模型分不清「真实远距离」和「占位符」。全部样本编码成 token_ids、pos1、pos2、adj 四个张量后,放进 DataLoader 按 batch 抛给模型,Shuffle 必须在 group by 句子长度之前做,否则同类长度永远同批,梯度方向偏斜。
4.3 训练超参数表与早停策略
图卷积网络的关系抽取模型超参数相对固定,容易出效果的一套配置大致如下,可以在此基础上再做网格搜索:
| 超参数 | 常用值 | 调整倾向 |
|---|---|---|
| 词向量 | 300 维 GloVe / 中文 300 维预训练 | 换领域词向量比调 lr 收益大 |
| 位置向量维度 | 50 | 小数据集增大到 100 无显著提升 |
| BiLSTM hidden | 200 | 数据量大可加到 300 |
| GCN 层数 | 2 | 超过 3 层 F1 下降 |
| dropout | 0.5 | 远程监督数据噪声大,调到 0.6 |
| batch size | 32 | 显存允许时 64 收敛更稳 |
| 学习率 | Adam 1e-3,或 SGD 1.0 配衰减 | 换预训练模型时改 2e-5 |
| 早停 patience | 10 | 每 2 个 epoch 在 dev 上评估一次 |
提示:早停只有一个原则——以 dev Macro-F1 为准,而不是以 loss 为准。关系抽取的类别不平衡很严重,loss 下降不代表 F1 上升,保存的最佳 checkpoint 是 dev F1 最高时的那份,不是最后一个 epoch 的。
远程监督数据集的 Other 类占比往往超过 70%,常见做法是随机负采样把 Other 压到 50% 左右再训练,否则模型会学成「永远输出 Other」也能拿到 70 准确率的假象。
4.4 评估指标:Macro-F1 的计算与 "Other" 类陷阱
关系抽取评测指标统一口径是 Macro-F1,即每个关系类别分别算精确率和召回率,再对所有类别取平均。这里有两个坑。第一,SemEval 官方评分是把 Other 类排除在平均之外的,因为 Other 样本量最大,把它算进去会把 F1 拉得虚高,不同论文之间的对比就失真了;第二,很多实现直接调 sklearn 的 f1_score(average="macro"),它默认把所有类别都算进去,和官方口径对不上,复现分数时一定要看评估脚本里是否有跳过 Other 的逻辑。
def semeval_macro_f1(preds, labels, relation_ids, other_id=0): eps = 1e-9 f1_list = [] for rid in relation_ids: if rid == other_id: continue # 官方口径:跳过 Other 再平均 tp = ((preds == rid) & (labels == rid)).sum() fp = ((preds == rid) & (labels != rid)).sum() fn = ((preds != rid) & (labels == rid)).sum() p = tp / (tp + fp + eps) r = tp / (tp + fn + eps) f1_list.append(2 * p * r / (p + r + eps)) return float(np.mean(f1_list))注意关系类别是遍历 relation_ids 里配置的真实类别集合,而不是从 0 到 num_classes 机械遍历,避免把没参与训练的下标圈进来。eps 的作用是防止某个类别在 batch 里没有正样本时除零。测试时把 preds 的 argmax 结果拿来做同样的计算,才能和论文基线对齐。先跑通 dev 再上测试集,dev 上 F1 和测试集相差超过 3 个点说明预处理有问题,不要急着调参。
5. 部署上线:加载训练好的模型做批量推理的三个关键动作
5.1 确认模型文件完整性与入口脚本
拿到训练好的模型,先别急着看部署文档,先写一段加载脚本验证权重能不能正常加载、输出形状是否和关系类别数一致:
ckpt = torch.load("checkpoints/best_model.pt", map_location="cpu") model.load_state_dict(ckpt["model_state_dict"]) model.eval() with torch.no_grad(): logits = model(*(dummy_batch)) assert logits.shape[-1] == num_relations如果 load_state_dict 报 missing 或 unexpected keys,多半是训练时用了 DataParallel,权重名带 module. 前缀,加载时手动剥掉前缀即可:
from collections import OrderedDict new_state = OrderedDict( (k.replace("module.", ""), v) for k, v in ckpt["model_state_dict"].items() ) model.load_state_dict(new_state)map_location="cpu" 是第一步先落 CPU,确认权重数值完整后再切 GPU,避免显存不足时报一堆难排查的 CUDA 错误。
5.2 ONNX 导出与动态轴设置
模型接进业务接口,常见做法是导出 ONNX,交给 ONNX Runtime 或 Triton 推理,而不是在服务进程里直接跑 PyTorch。导出时最关键的参数是 dynamic_axes——关系抽取的输入序列长度不固定,必须把 seq 维度设成动态,否则导出后只能用固定 max_len 输入,短句子也会被 pad 到 128 甚至 256,推理延迟白涨一倍。
dummy_token = torch.zeros(1, 128, dtype=torch.long) dummy_pos1 = torch.zeros(1, 128, dtype=torch.long) dummy_pos2 = torch.zeros(1, 128, dtype=torch.long) dummy_adj = torch.zeros(1, 128, 128, dtype=torch.float32) torch.onnx.export( model, (dummy_token, dummy_pos1, dummy_pos2, dummy_adj), "re_gcn.onnx", input_names=["token_ids", "pos1", "pos2", "adj"], output_names=["logits"], dynamic_axes={ "token_ids": {0: "batch", 1: "seq"}, "pos1": {0: "batch", 1: "seq"}, "pos2": {0: "batch", 1: "seq"}, "adj": {0: "batch", 1: "seq", 2: "seq"}, }, opset_version=17, )动态轴里 adj 要同时放开 batch 和两个 seq 维度,因为邻接矩阵的形状由 batch 内最大句子长度决定。opset 版本越高算子融合越好,但需要确认部署端的 ONNX Runtime 版本支持,一般 opset 15 到 17 是稳妥区间。导出后用 onnxruntime 加载跑一遍 dummy 输入,和 PyTorch 输出对比,最大误差超过 1e-4 就要查是不是模型里有 eval 模式才生效的 dropout。
5.3 推理侧的两个工程细节
第一个细节是依赖解析器和模型必须用同一套分词粒度。训练时的 token 切分方式决定了依赖树结构和实体位置索引,部署时如果换一个分词器,切出的词序列不同,邻接矩阵就错位,预测结果直接崩。所以部署进程里要把分词器和依赖解析器随模型一起打包,而不是在业务侧各自为政。第二个细节是 batch 推理的 padding 策略,GCN 的邻接矩阵是 [B, L, L],同一 batch 里长短句差距大时,padding 到最长句子的计算浪费非常明显。一个有效做法是按句子长度分桶,比如 <10、10–20、20–40、>40 四桶,各自成 batch 送推理,ONNX Runtime 下通常能带来 30% 到 50% 的吞吐提升。
验证部署结果时,不要只看端到端功能「能不能出结果」,而是从 dev 集随机抽 500 条,同时跑离线模型和线上接口,对比预测结果一致性;一致率低于 99% 说明两头之间某一环(分词、依赖解析、实体标注)对不齐,先回到预处理管道里查偏差。
本文还有配套的精品资源,点击获取