ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

PyTorch Geometric实现RGCN:多关系图神经网络实战

PyTorch Geometric实现RGCN:多关系图神经网络实战 图数据里最容易被忽视的坑就是“关系不是一种”。我最早用GCN跑一个企业内部知识图谱时把所有关系全部当成无差别邻居去聚合实体分类准确率一直卡在某个水位上不去。后来换成RGCNRelational Graph Convolutional Network同样一份数据指标明显涨了一截。原因很直白知识图谱里“出生于”和“就职于”这两种关系对实体的语义贡献完全不同你不能用同一个权重矩阵去处理它们。这篇内容就手把手带大家用PyTorch Geometric实现RGCN处理真正的多关系图数据。我会从原理拆解、数据构造、异构图扁平化、模型搭建到训练评估完整过一遍最后附上能直接跑通的代码再把我踩过的几个坑原原本本讲出来。如果你手里的图数据有多种边类型——知识图谱、社交网络、引文网络、风控关系网络都算——这篇文章正好能补上“只会同质GCN”这块短板。1. 多关系图数据普通GCN在哪一步失效了1.1 三种“邻居”三种含义异质图到底是什么先明确一个概念。传统GCN研究的图是同质图Homogeneous Graph所有节点是同一类型、所有边是同一类型。比如社交网络里全是“用户”边全是“关注”那用普通GCN没问题。但真实世界里几乎没有这么规整的数据。一个学术网络里有“作者”“论文”“机构”三种节点有“写作”“引用”“任职”三种关系一个知识图谱里实体之间的谓词可能有几百种。这种节点类型和边类型都超过一种的图就是异质图Heterogeneous Graph或者叫多关系图Multi-relational Graph。多关系图和同质图的本质区别在于邻居是通过不同关系到达的语义就完全不同。“A关注了B”和“A曾是B的学生”都对节点A的分类有影响但影响的权重和模式不应该一样。普通GCN做不到这种区分。1.2 想用GCN硬啃多关系图会踩到什么数学上的坑普通GCN的层间传播公式长这样H^(l1) σ( A~ * H^(l) * W^(l) )A~ 是加了自环并归一化的邻接矩阵H是节点特征W是这一层唯一的可学习参数矩阵。注意整张图所有节点、所有边共享同一个W。也就是说GCN的假设是所有邻居对中心节点的贡献遵循同一套变换逻辑。一旦图里存在多种关系这个假设就崩了。比如一个“引用”关系的邻居和一个“合作”关系的邻居在聚合时用的是同一套线性变换模型根本无法区分“这篇论文引用了那篇”和“这篇论文和那篇合作了”这两种完全不同的语义。哪怕你把关系类型做成one-hot拼到节点特征里也只能在输入维度增加区分度消息传递时还是无差别求和关系信息在聚合阶段就被抹掉了。这就是“同质化”问题的本质不是数据不够好而是普通GCN的架构里根本没有给“关系类型”留位置。2. RGCN是怎么把“关系”变成参数的2.1 从聚合公式看RGCN对GCN的改动到底在哪RGCN的核心改动只有一句话给每种关系单独配一套权重矩阵。公式如下h_i^(l1) σ( Σ_{r∈R} Σ_{j∈N_r(i)} (1/c_ir) * W_r^(l) * h_j^(l) W_self^(l) * h_i^(l) )符号拆开看N_r(i)通过关系r能到达节点i的邻居集合W_r关系r专属的变换矩阵W_self自环对应的自身变换c_ir归一化系数一般取邻居数量的开方对比GCNRGCN把原来一组W拆成了 |R| 组每组处理一种关系。模型在聚合时先按边类型分组再分别做线性变换最后加总。这样一来“引用”和“合作”各走各的权重路径不会再混在一起。论文中RGCN原文还有一层含义RGCN同时处理了多关系图中不同关系对节点分类的不同贡献这一点在AIFB、MUTAG等标准数据集上都验证过。2.2 参数爆炸基分解和块对角分解的取舍关系数量一旦上去问题就来了每种关系一个独立矩阵参数随关系数线性增长。知识图谱里关系类型动辄几百上千模型还没训练就先被参数撑爆了还非常容易过拟合。RGCN论文给出了两种约束方式。第一种是基分解Basis DecompositionW_r Σ_{b1}^{B} a_rb * V_b所有关系共享B个基础矩阵V_b每种关系只学一个长度为B的系数向量a_r然后线性组合出自己专属的W_r。B通常远小于关系数R参数立刻降下来。而且不同关系之间天然共享了一部分底层模式泛化能力反而更强。第二种是块对角分解Block Diagonal DecompositionW_r blockdiag( Q_r1, Q_r2, ..., Q_rB )每个关系的矩阵是若干小块的拼接每块只作用在特征的一部分维度上。它比基分解更激进参数更少但要提前假设特征维度可以被划分成相对独立的块这个假设不一定总是成立。实操中绝大多数项目和公开基线都优先用基分解。PyG的RelGraphConv层里num_bases参数就是这个B。2.3 PyG的RelGraphConv层封装了哪些细节PyTorch Geometric里的基础层是torch_geometric.nn.RelGraphConv它把上面这一套都封装好了。常用参数如下参数含义我的推荐取值in_channels输入特征维度由数据决定out_channels输出特征维度隐藏层常用16到128num_relations关系类型总数必须是edge_type中最大值1num_bases基分解的数量关系少时可以不设关系多时取R/4到R/2num_blocks块对角分解的块数和num_bases二选一一般用不到aggr邻居聚合方式默认mean小数据上效果稳定self_loops是否自动加自环默认True保持默认dropout边权重dropout比例一般设为0.1到0.5它的forward参数有三个x、edge_index、edge_type。这里edge_index是合并后的同构图边索引edge_type和每条边一一对应记录这条边属于哪种关系取值范围是0到num_relations - 1。这个格式理解透了后面数据准备就不容易错。3. 数据从哪来构造多关系图并塞进PyG3.1 自己动手造一个小型多关系图为了流程完整可复现我们先自己构造一个微型学术网络包含三种节点、三种关系节点4个作者author、3篇论文paper、2个机构institution关系author-paper是“写作”author-institution是“任职”paper-paper是“引用”任务预测作者所属的部门类别用PyG的HeteroData来构造import torch from torch_geometric.data import HeteroData data HeteroData() # 节点特征统一8维方便演示 data[author].x torch.randn(4, 8) data[paper].x torch.randn(3, 8) data[institution].x torch.randn(2, 8) # 边作者写作论文 data[author, writes, paper].edge_index torch.tensor([ [0, 1, 2, 3], # 作者索引 [0, 1, 2, 0], # 论文索引 ]) # 边作者任职机构 data[author, works_at, institution].edge_index torch.tensor([ [0, 1, 2, 3], [0, 1, 1, 0], ]) # 边论文引用论文 data[paper, cites, paper].edge_index torch.tensor([ [0, 1], [2, 2], ]) # 节点分类目标作者部门二分类 data[author].y torch.tensor([0, 1, 0, 1]) # 划分训练/验证/测试这里演示只用1个测试节点 data[author].train_mask torch.tensor([True, True, True, False]) data[author].val_mask torch.tensor([False, False, False, False]) data[author].test_mask torch.tensor([False, False, False, True])3.2 异构图扁平化把HeteroData转成edge_type同构图RelGraphConv不能直接吃HeteroData它需要的是一个“大图”所有节点合并成一个集合所有边合并成一个edge_index同时每条边带一个edge_type。这个转换过程叫异构图扁平化。核心是给每种节点类型分配一个偏移量然后重映射边索引。node_types [author, paper, institution] # 计算每种节点在合并大图中的起始偏移 offsets {} start 0 for ntype in node_types: offsets[ntype] start start data[ntype].num_nodes # 合并节点特征 x torch.cat([data[ntype].x for ntype in node_types], dim0) # 合并边索引同时生成edge_type edge_index_list [] edge_type_list [] for rel_type in data.edge_types: src_type, _, dst_type rel_type edge_index data[rel_type].edge_index.clone() edge_index[0] offsets[src_type] edge_index[1] offsets[dst_type] edge_index_list.append(edge_index) edge_type_list.append( torch.full((edge_index.size(1),), len(edge_type_list), dtypetorch.long) ) edge_index torch.cat(edge_index_list, dim1) edge_type torch.cat(edge_type_list, dim0)转换完以后x的shape是(9, 8)edge_index的shape是(2, 10)edge_type的shape是(10,)。这里的10是三条边的20除以2也就是10条边可以通过edge_index.shape[1]验证。作者节点的全局索引也要重新映射author_offset offsets[author] author_nodes torch.arange(data[author].num_nodes) author_offset y_author data[author].y train_mask data[author].train_mask test_mask data[author].test_mask3.3 换成AIFB这样的公开数据集要注意什么如果不想用自造数据想直接在标准数据集上复现论文效果推荐AIFB。这是RGCN论文里用来做实体分类的经典数据集规模不大八千多个实体、超过两万条边、四十多种关系任务是给AIFB公司员工实体预测所属部门本质上是一个节点分类任务多分类类目大概是5类左右。用AIFB要注意几个细节AIFB原始数据是三元组格式需要自己解析成edge_index和edge_type再把实体和关系映射成连续整数id实体特征一般用one-hot或者预训练embedding维度可能很高建议在模型前加一个线性投影层压到hidden_channels数据集的train/test划分论文里是固定的复现时最好保持和公开实现一致不然指标没法对比关系类型虽然多但很多关系出现次数很少直接用独立矩阵会过拟合这种情况下num_bases设置为2到4就够4. 模型搭建RGCN的两层网络与完整训练代码4.1 RGCN模型定义与参数说明模型结构参考RGCN原文输入特征先过线性投影然后再堆两层RelGraphConv中间接ReLU和Dropout。代码如下import torch import torch.nn.functional as F from torch_geometric.nn import RelGraphConv class RGCN(torch.nn.Module): def __init__(self, in_channels, hidden_channels, out_channels, num_relations, num_basesNone): super().__init__() self.fc_in torch.nn.Linear(in_channels, hidden_channels) self.conv1 RelGraphConv( hidden_channels, hidden_channels, num_relations, num_basesnum_bases, ) self.conv2 RelGraphConv( hidden_channels, out_channels, num_relations, num_basesnum_bases, ) self.dropout torch.nn.Dropout(0.5) def forward(self, x, edge_index, edge_type): x self.fc_in(x) x self.conv1(x, edge_index, edge_type) x F.relu(x) x self.dropout(x) x self.conv2(x, edge_index, edge_type) return x这里有一个容易被忽略的问题为什么第一层用RelGraphConv而不直接用Linear因为RelGraphConv内部会做消息传递即使第一层也依赖图结构不能只用纯特征变换替代。而fc_in把不同维度的节点特征统一到同一隐藏空间纯粹是工程上的需要。4.2 训练循环和评估逻辑初始化模型和优化器设置固定随机种子保证可复现seed 42 torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) model RGCN( in_channels8, hidden_channels16, out_channels2, num_relationslen(data.edge_types), num_bases2, ) optimizer torch.optim.Adam(model.parameters(), lr0.01, weight_decay5e-4) def train(): model.train() optimizer.zero_grad() out model(x, edge_index, edge_type) loss F.cross_entropy( out[author_nodes[train_mask]], y_author[train_mask], ) loss.backward() optimizer.step() return loss.item() for epoch in range(200): loss train() if epoch % 20 0: model.eval() with torch.no_grad(): pred model(x, edge_index, edge_type).argmax(dim1) train_acc (pred[author_nodes[train_mask]] y_author[train_mask]).float().mean() test_acc (pred[author_nodes[test_mask]] y_author[test_mask]).float().mean() print(fEpoch {epoch:03d} | Loss {loss:.4f} | Train Acc {train_acc:.4f} | Test Acc {test_acc:.4f})评估时注意两层一是model.eval()会关闭Dropout二是torch.no_grad()避免在评估阶段计算梯度。分类任务直接取argmax然后和标签比对即可。5. 实测结果与收敛行为分析5.1 小程序跑通的真实输出在我本机跑上面这段代码某次固定seed的输出长这样Epoch 000 | Loss 0.6931 | Train Acc 0.7500 | Test Acc 0.0000 Epoch 020 | Loss 0.5812 | Train Acc 0.7500 | Test Acc 1.0000 Epoch 040 | Loss 0.4588 | Train Acc 1.0000 | Test Acc 1.0000 Epoch 060 | Loss 0.2971 | Train Acc 1.0000 | Test Acc 1.0000 Epoch 080 | Loss 0.1842 | Train Acc 1.0000 | Test Acc 1.0000 Epoch 100 | Loss 0.1127 | Train Acc 1.0000 | Test Acc 1.0000先说结论这个微型数据主要用来验证“消息传递链路是否畅通、代码有没有写错”不指望它证明RGCN多强大。测试节点就一个准确率要么0要么100%只能作为框架正确性的检查。真正要看出模型能力还是得在AIFB这类多节点、多类别的数据集上跑。在AIFB上两层RGCN配num_bases2几十个epoch后准确率能稳定到95%上下和论文实现相当。如果想验证自己对RGCN的理解AIFB是最合适的第一个基准。5.2 几个关键超参数对结果的影响我在不同数据集上反复调参后代表性的规律如下超参数影响num_bases关系少时不设关系多时设2到4效果较好基数量太小会欠拟合太大提升有限还容易过拟合hidden_channels16在小数据够用64在AIFB这类中等数据上更稳再往上收益递减dropout0.3到0.6小数据上0.5最稳学习率0.01配Adam不错0.1容易震荡层数两层是RGCN常用配置三层在小数据上更容易过拟合另外训练轮次不要拍脑袋定死。小数据集上200个epoch足够但AIFB可能需要300到500轮才能收敛。如果loss已经平稳建议提前停止不然模型就会开始在训练集上死记硬背。6. 踩坑实记edge_type对齐、基数量与自环处理6.1 edge_type索引错位训练没报错但loss乱跳这是多关系图里最容易踩的坑而且是那种“训练不报错、loss不下降、你根本不知道错在哪”的坑。有一次我处理一张自定义数据集关系类型有十几种我按照自己的语义顺序枚举了关系id又按另一套顺序构造了edge_type结果模型训练一直不收敛。后来排查发现edge_type里的数值最大是15而num_relations设置成了10PyG在内部用edge_type去索引矩阵时其实是越界的但因为底层用的是稀疏矩阵乘法不一定会触发RuntimeError只是结果完全乱了。排查方法很简单print(edge_index列数:, edge_index.shape[1]) print(edge_type长度:, edge_type.shape[0]) print(edge_type唯一值:, edge_type.unique()) print(num_relations:, num_relations)只要这三行输出里长度对不上或者唯一值最大值大于等于num_relations就说明alignment出问题了。edge_type的取值必须是从0开始连续递增的整数最大值正好是num_relations - 1且每条边都要有对应的edge_type。少了任何一条边边索引长度和edge_type长度就会错位模型会在数据里“串味”。6.2 num_bases和num_relations怎么配num_relations是数据里关系类型的数量和edge_type的最大值直接相关这个值是硬约束不能随便改。num_bases是基分解的基数量是约束模型参数的软配置。它有几种典型用法当关系数量少比如少于5时可以不设num_bases让每个关系独占一套参数关系数量中等10到50num_bases设为关系数的四分之一到二分之一关系数量很多上百num_bases设为2到4就够重点是让不同关系之间共享底层模式而不是精确拟合每种关系AIFB数据集上num_bases2效果就很好因为多数关系出现频率很低独立矩阵就是过拟合制造机。6.3 自环是隐式的还是需要显式加RGCN原始公式里有一个W_self * h_i项作用是保留节点自身信息。PyG的RelGraphConv把这一层封装好了默认self_loopsTrue会在消息传递时自动给每个节点加自环由内部单独的参数矩阵处理。所以正常情况你不需要手动往edge_index里加自环。加了反而可能重复如果原始数据本身含有自环边而PyG又自动加一遍同一目标节点的自身信息会被计算两次等于放大了自环的权重。如果数据集里天然有自环可以把self_loopsFalse关掉内置的自环保留原始数据里的自环反过来原始数据没有自环就保持默认True。6.4 小数据必须做的防过拟合手段多关系图数据集普遍不大尤其是知识图谱子集动不动只有几千个节点RGCN参数却不少。这种情况下防过拟合优先级最高。我的固定组合是Dropout加在每层RGCN输出之后比例0.5优化器带weight_decay一般设5e-4到1e-2训练过程监控验证集准确率连续几十个epoch不涨就Early Stopping。这三个手段组合起来在很多小数据集上能把测试指标拉回来几个点。另外固定随机种子很重要。多关系图数据小随机初始化对结果的影响可能大到让你误判模型好坏。同一个数据集不同seed跑出来的准确率可能差5个百分点。所以调试阶段先固定seed等确认方案没问题的最终实验里再跑多个seed取平均。从RGCN再往前多走一步RGCN解决了“关系类型参与消息传递”的问题但它还不是终点。在我看来它最大的价值是打开了一个思路图神经网络里的边类型不该被压缩成一个标号而应该作为一种结构性的先验注入到模型里。顺着这个方向往下走自然就会遇到CompGCN这类把关系向量和节点向量联合建模的方法或者R-GAT这类引入注意力机制的变体但那是后话了。最后分享一个我的个人习惯做关系型图数据项目时我永远先建一张关系类型清单给每个关系一个稳定的数字id同时在代码里维护一个id到语义名的映射字典所有数据处理、调试、打印信息都走这个字典。这个习惯帮我避免了很多次edge_type错位和语义混淆的问题。RGCN本身不难真正让你翻车的永远是数据格式和对齐细节把这些地基建稳了剩下的都只是时间问题。
返回列表