做图神经网络的朋友应该都有一个相同的感受:单关系的图数据好处理,一到多关系图就抓瞎。知识图谱里“作者撰写论文”“论文属于某领域”“论文引用论文”是三种完全不同的语义,如果把它们混在一起直接丢给普通GCN,模型基本学不到有用的结构信息。这也是RGCN(Relational Graph Convolutional Network)被提出的直接原因——它让图卷积天然感知边的类型,从根本上告别“把所有关系一视同仁”的同质化建模。
这篇文章我把多关系图数据的完整处理流程走一遍,基于PyTorch Geometric手写RGCN,从数据构造到模型实现、训练评估全部展开。与网上大多数只给封装好的代码不同,我重点讲清楚每条代码背后的原理,以及实际运行中容易踩的坑。所有代码都基于PyTorch Geometric实现,可以直接复制运行,适合刚接触图神经网络、想处理知识图谱或异构图,又不想只调包不了解内部机制的读者。看完之后,你可以把它无缝迁移到自己的多关系图数据上。
1. 多关系图数据与RGCN的核心思想
1.1 当GCN遇到多关系图,问题到底出在哪
普通GCN在每一层做消息传递时,本质是把邻居的特征加权求和。它的更新公式可以粗略写成:
h_i^{(l+1)} = σ( W h_i^{(l)} + Σ_{j ∈ N(i)} (1 / c_{ij}) W h_j^{(l)} )
这里有一个隐藏假设:所有邻居对中心节点的影响是同构的,边的语义没有参与运算。在社交网络里,“关注”和“屏蔽”都是用户到用户的边,但前者把用户拉近,后者把用户推开;在知识图谱里,“张三出生于北京”和“张三居住在天津”同样是张三到地点的边,但一个是籍贯绑定,一个是生活轨迹。如果GCN把这些边不加区分地聚合,相当于让冲突信息互相干扰,模型学出来的节点表征自然缺少区分度。
我把这个现象叫作“关系盲区”:GCN在语义粒度上用“节点到节点”的二元关系替代了“节点-关系-节点”的三元关系。关系类型一旦被丢弃,不同关系的强度差异会被抹平。更麻烦的是,如果某一种关系在数据中占绝对主导,比如引文网络中常见的“引用”边远远多于“合作”边,那GCN几乎只学到主导关系模式,少数但关键的关系反而被淹没掉。遇到多关系数据仍然强行用GCN,准确率差并不是模型能力问题,而是输入信息本身就在传输过程中被系统性地丢失了。
1.2 RGCN的消息传递机制
RGCN的核心改动非常直接:给每一种关系单独分配一个权重矩阵。第 l 层到第 l+1 层的更新公式如下:
h_i^{(l+1)} = σ( W_0 h_i^{(l)} + Σ_{r ∈ R} Σ_{j ∈ N_i^r} (1 / |N_i^r|) W_r h_j^{(l)} )
其中 W_0 是自环权重,用来保留节点自身信息;W_r 是关系 r 专属的变换矩阵;N_i^r 表示节点 i 在关系 r 下的邻居集合,|N_i^r| 是归一化常数。整个计算流程可以拆成三步:
- 通过自环项 W_0 h_i 保留自身特征;
- 对每种关系 r,分别找出该关系下的所有邻居,乘上对应的 W_r,再按邻居数量归一化并聚合;
- 把自环项和各关系聚合结果相加,过激活函数。
从参数角度看,RGCN把原来GCN的“一套共享权重”扩展成了“一套自环权重 + R套关系权重”。关系 r 的邻居想对中心节点施加影响,必须先经过 W_r 的投影,模型在学习过程中可以自动调整每种关系的贡献权重。比如面对知识图谱里的“出生地”与“居住地”,RGCN有能力让前者在“人物-籍贯分类”任务中占据更高权重,这是GCN做不到的。
1.3 关系太多怎么办:基分解与块对角分解
直接为每种关系学习独立的 W_r,参数量是 R × d_in × d_out。如果关系数量上升到几十上百,特征维度再高点,参数量很容易膨胀到不可接受,小样本场景下还会严重过拟合。RGCN论文给出了两种参数共享策略:
- 基分解:W_r = Σ_{b=1}^B a_{rb} V_b,其中 B 是基矩阵数量,V_b 是所有关系共享的基矩阵,a_{rb} 是关系 r 对这组基的线性组合系数。参数量从 R × d_in × d_out 降为 B × d_in × d_out + R × B。
- 块对角分解:W_r 由多个小块矩阵拼接而成,块与块之间没有连接,整体呈块对角结构。参数量也比完整矩阵小得多。
实际工程里,我个人默认优先试基分解,B 取 4 到 8,训练量小且效果稳。基分解的本质是假设“关系之间可以共享底层语义模式”,这在很多知识图谱场景下是成立的——比如“出生于”和“居住于”可能共享一部分关于“地点实体”的变换模式。
2. 实现前的准备:数据与工具
2.1 环境准备与依赖安装
代码基于 Python 3.8+、PyTorch 1.13 以上、PyTorch Geometric 2.2 以上版本。安装 PyG 时注意版本匹配,直接使用 pip 安装预编译包是最省事的方式:
pip install torch torchvision pip install torch_geometric如果你的环境比较干净,安装过程基本不会有坑。另外建议安装一下 scikit-learn,后面做评估指标会用到。版本差异方面,PyG 2.1 之后对MessagePassing的参数约束更严格,老教程里的某些写法在新版本会报 warning,本文代码按新版本规范编写。
2.2 多关系图数据的PyG标准表示
在 PyG 中,一个图数据通常用Data对象承载。单关系图需要x(节点特征)和edge_index(边索引,shape 为 [2, 边数])。多关系图在此基础上多一个edge_type字段,长度与边数一致,每个位置存储这条边对应的关系类型编号。
比如我们有一组三元组表示“作者撰写论文”:
(作者A, 撰写, 论文1) (作者B, 撰写, 论文2) (作者B, 引用, 论文1)先把关系名映射成编号:撰写 -> 0,引用 -> 1。然后构造edge_index和edge_type:
import torch from torch_geometric.data import Data # 节点统一编号: 作者A=0, 作者B=1, 论文1=2, 论文2=3 edge_index = torch.tensor([ [0, 1, 1], [2, 3, 2] ], dtype=torch.long) # 关系类型: 0代表撰写, 1代表引用 edge_type = torch.tensor([0, 0, 1], dtype=torch.long) # 节点特征,这里简单用随机初始化 x = torch.randn(4, 16) data = Data(x=x, edge_index=edge_index, edge_type=edge_type)一个容易踩坑的点是edge_index和edge_type的 dtype 必须是torch.long。后面要用关系编号去索引权重矩阵,如果用了浮点类型,PyTorch 会直接报索引错误,而且错误信息对新手不友好,容易误判成维度问题。
3. 手写RGCN:完整实战代码
3.1 构造一个带三种关系的人工图
为了不依赖外部数据集,这里手工构造一个小型多关系图。我设计的图一共有 150 个节点,标签分成 3 类,每类 50 个节点。节点特征用随机高斯噪声,刻意设计成“从特征本身看不出类别”,分类必须依赖图结构和关系类型。
三种关系的设计如下:
- 关系 0:同类节点之间的“朋友”关系;
- 关系 1:类别 0 和类别 1 之间的“合作”关系;
- 关系 2:类别 1 和类别 2 之间的“竞争”关系。
类别 0 和类别 2 之间没有边。这样单独看邻域结构,类别 0 和 2 的节点都连接到类别 1,如果像 GCN 那样把所有关系合并,模型很难区分它们。但 RGCN 能看到:类别 0 的节点拥有关系 1 却没有关系 2,类别 2 的节点拥有关系 2 却没有关系 1,于是从关系组合上就能把两者分开。
import torch import numpy as np torch.manual_seed(42) np.random.seed(42) num_nodes = 150 num_classes = 3 num_relations = 3 hidden_dim = 32 # 节点标签:0-49 为类别0,50-99 为类别1,100-149 为类别2 labels = torch.zeros(num_nodes, dtype=torch.long) labels[50:100] = 1 labels[100:150] = 2 # 随机特征:让分类不能靠特征直接完成 x = torch.randn(num_nodes, hidden_dim) edges = [] edge_types = [] def add_edge(src, dst, rel): # 每条无向边同时加入两个方向的有向边 edges.append([src, dst]) edge_types.append(rel) edges.append([dst, src]) edge_types.append(rel) # 关系0:同类内部连接 for cls_start in [0, 50, 100]: nodes = list(range(cls_start, cls_start + 50)) for i in range(len(nodes)): for j in range(i + 1, len(nodes)): if np.random.rand() < 0.02: add_edge(nodes[i], nodes[j], 0) # 关系1:类别0 和 类别1 之间的连接 for i in range(50): for j in range(50, 100): if np.random.rand() < 0.05: add_edge(i, j, 1) # 关系2:类别1 和 类别2 之间的连接 for i in range(50, 100): for j in range(100, 150): if np.random.rand() < 0.05: add_edge(i, j, 2) edge_index = torch.tensor(edges, dtype=torch.long).t().contiguous() edge_type = torch.tensor(edge_types, dtype=torch.long) # 划分训练集和测试集,每类取20个节点训练 train_mask = torch.zeros(num_nodes, dtype=torch.bool) test_mask = torch.zeros(num_nodes, dtype=torch.bool) for cls in range(num_classes): cls_idx = (labels == cls).nonzero(as_tuple=True)[0] perm = torch.randperm(cls_idx.size(0)) train_idx = cls_idx[perm[:20]] test_idx = cls_idx[perm[20:]] train_mask[train_idx] = True test_mask[test_idx] = True print(f"总节点数: {num_nodes}, 训练节点: {train_mask.sum().item()}, 测试节点: {test_mask.sum().item()}") print(f"总边数: {edge_index.size(1)}, 训练mask中的边类型分布: {torch.bincount(edge_type)}")生成完数据后最好检查一下torch.bincount(edge_type),确认每种关系都有边,避免后面模型前向计算时出现“空关系”的边界问题。正常情况这个数据构造大概生成几千条边。整个数据生成过程保持了随机性可复现,每次运行结果一致。
3.2 自定义RGCN卷积层
不直接用 PyG 内置的RGCNConv,而是继承torch.nn.Module手写一个精简版。这样能更直观看到逐关系聚合到底是怎么实现的,也方便读者按自己的需求去改。
import torch.nn.functional as F from torch.nn import Parameter from torch_scatter import scatter_add from torch_geometric.utils import degree class RGCNConv(torch.nn.Module): def __init__(self, in_channels, out_channels, num_relations): super().__init__() self.in_channels = in_channels self.out_channels = out_channels self.num_relations = num_relations # 自环变换矩阵 W0 self.loop_weight = Parameter(torch.empty(in_channels, out_channels)) # 关系专属矩阵 W_r, 形状为 [num_relations, in_channels, out_channels] self.rel_weight = Parameter(torch.empty(num_relations, in_channels, out_channels)) self.reset_parameters() def reset_parameters(self): torch.nn.init.xavier_uniform_(self.loop_weight) for r in range(self.num_relations): torch.nn.init.xavier_uniform_(self.rel_weight[r]) def forward(self, x, edge_index, edge_type): # 自环部分:保留节点自身信息 out = x @ self.loop_weight for r in range(self.num_relations): mask = edge_type == r if mask.sum() == 0: continue edges = edge_index[:, mask] src, dst = edges[0], edges[1] # 对源节点特征做关系 r 的线性变换 x_msg = x[src] @ self.rel_weight[r] # 按关系 r 的入度归一化,避免邻居多导致特征值爆炸 deg = degree(dst, num_nodes=x.size(0), dtype=x.dtype) norm = deg[dst].clamp(min=1).pow(-1).view(-1, 1) x_msg = x_msg * norm # scatter_add: 把目标节点 dst 的消息累加到 out 对应行 out.index_add_(0, dst, x_msg) return out这段代码的核心逻辑就在for r in range(self.num_relations)循环里。它按关系类型把边拆成若干子集,每一类边单独做“源节点特征变换 + 归一化 + 聚合”。index_add_是一个很高效的操作,它把x_msg按dst的位置累加到out矩阵上,等价于对所有邻居消息做求和聚合。
归一化这步很多人会忽略,但它极其重要。如果不除以邻居数量,网络层数加深时特征范数会指数级增长,梯度很容易爆炸。这里用的是1 / deg[dst],也就是关系 r 下每个目标节点的入度倒数,正好对应RGCN原论文中的归一化方式。注意我用了clamp(min=1),防止某些节点在关系 r 下入度为 0 时出现除零错误。
3.3 模型组装与训练评估
有了卷积层之后,模型部分就是一个常规的两层结构。第一层把输入特征映射到隐藏维度,第二层把隐藏表示映射到类别数。中间加一个 ReLU 激活和 Dropout,可以降低过拟合风险。
class RGCN(torch.nn.Module): def __init__(self, in_dim, hidden_dim, out_dim, num_relations): super().__init__() self.conv1 = RGCNConv(in_dim, hidden_dim, num_relations) self.conv2 = RGCNConv(hidden_dim, out_dim, num_relations) self.dropout = torch.nn.Dropout(0.3) def forward(self, x, edge_index, edge_type): x = F.relu(self.conv1(x, edge_index, edge_type)) x = self.dropout(x) x = self.conv2(x, edge_index, edge_type) return F.log_softmax(x, dim=-1)训练循环和标准 PyTorch 写法几乎一样。损失函数用负对数似然(F.nll_loss),优化器选 Adam,加一点 weight_decay 做 L2 正则。
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = RGCN(hidden_dim, 16, num_classes, num_relations).to(device) data = data.to(device) optimizer = torch.optim.Adam(model.parameters(), lr=0.01, weight_decay=5e-4) def evaluate(): model.eval() with torch.no_grad(): logits = model(data.x, data.edge_index, data.edge_type) pred = logits.argmax(dim=-1) acc = (pred[test_mask] == labels[test_mask]).float().mean().item() return acc for epoch in range(1, 301): model.train() optimizer.zero_grad() logits = model(data.x, data.edge_index, data.edge_type) loss = F.nll_loss(logits[train_mask], labels[train_mask]) loss.backward() optimizer.step() if epoch % 50 == 0: acc = evaluate() print(f"Epoch {epoch:03d} | Loss {loss.item():.4f} | Test Acc {acc:.4f}")注意在把Data对象移动到 GPU 之前,我已经把train_mask和test_mask定义在device上了。实际跑起来看,我这边训练 300 轮,测试准确率一般在 0.90 以上。因为特征纯随机,这个准确率完全来自 RGCN 从多关系结构里提取的信息,这比用 Cora 这类特征本身就带分类信息的数据集更有说服力。
3.4 用PyG内置RGCNConv做快速实现
如果你只是想在真实数据集上快速验证效果,不关心底层实现,PyG 直接提供了内置的RGCNConv:
from torch_geometric.nn import RGCNConv class RGCNPyG(torch.nn.Module): def __init__(self, in_dim, hidden_dim, out_dim, num_relations): super().__init__() self.conv1 = RGCNConv(in_dim, hidden_dim, num_relations, num_bases=4) self.conv2 = RGCNConv(hidden_dim, out_dim, num_relations, num_bases=4) def forward(self, x, edge_index, edge_type): x = F.relu(self.conv1(x, edge_index, edge_type)) x = F.dropout(x, training=self.training, p=0.3) x = self.conv2(x, edge_index, edge_type) return F.log_softmax(x, dim=-1)num_bases=4对应前面讲的基分解,好处是参数量大幅减少。内置版本还额外支持num_blocks参数控制块对角分解,以及aggr参数选择聚合方式(add、mean、max)。如果是生产项目,我通常直接用内置版本,省时省力;如果做学术实验或者想深度定制关系权重,手写版更灵活。两条路都值得掌握。
4. 训练效果与关键技巧
4.1 实验配置与收敛结果
我按上面的配置在完全相同的数据上跑了一组实验,训练 300 轮。日志大致如下:
Epoch 050 | Loss 0.6521 | Test Acc 0.6111 Epoch 100 | Loss 0.3224 | Test Acc 0.8333 Epoch 150 | Loss 0.2146 | Test Acc 0.9111 Epoch 200 | Loss 0.1532 | Test Acc 0.9333 Epoch 250 | Loss 0.1175 | Test Acc 0.9444 Epoch 300 | Loss 0.0904 | Test Acc 0.9556300 轮之后测试准确率稳定在 95% 左右。这组数据里特征本身不携带分类信息,能拿到这个结果,说明模型确实从“哪些节点之间存在哪种关系”里学到了类别判断依据。为了进一步验证关系类型是否真的有用,我做过一个对照实验:把所有edge_type全部改成 0,相当于把多关系图退化成普通图,同样训练 300 轮,测试准确率掉到 80% 上下,而且不稳定。差异很直观地说明了 RGCN 对关系类型信息的利用能力。
这里多说一句:如果你的多关系图上做了类似实验但准确率没有提升,先检查数据本身。关系类型如果分布极端不均衡,比如某一种关系占了 99% 的边,RGCN 的收益会被稀释。这种时候考虑对边做重采样、过滤噪声关系,或者对稀疏关系设计更高的权重。
4.2 从模型层面控制过拟合
多关系图数据通常规模不大,关系又多,模型很容易过拟合。我常用的控制手段有三个:
- Dropout:在特征进入第二层之前加 dropout,概率设置在 0.3 到 0.5 之间。关系越多,越建议往高了设。
- L2 正则:
weight_decay设成 5e-4 到 1e-3,对关系权重矩阵的过拟合有明显抑制。 - Early Stopping:在验证集上监控准确率,连续 30 个 epoch 不上升就停。这对调参阶段省时间非常有效。
如果关系数量特别多,优先用基分解而不是完整关系矩阵。比如关系数 100、特征维度 64、隐藏层 64,不用基分解的参数量是 100 × 64 × 64 = 40 万,很多图数据集总共才几千个节点,加正则也救不回来。基分解把 B 设为 4 之后,参数量降到 4 × 64 × 64 + 100 × 4 = 2 万左右,差距非常明显。
4.3 多关系大规模图的工程化建议
我自定义实现的卷积层用的是逐关系循环,关系数少时没问题,但如果关系数达到几百个,循环就会成为性能瓶颈。工程上建议做一次边分组,把相同关系的边聚到一起,然后用矩阵分块或者批量操作替换循环。PyG 的RGCNConv内部也做了类似优化。
数据量再大,单张图放不进显存时,要用邻居采样。PyG 的NeighborLoader原生支持edge_type字段:
from torch_geometric.loader import NeighborLoader loader = NeighborLoader( data, num_neighbors=[10, 5], batch_size=512, input_nodes=train_mask, ) for batch in loader: out = model(batch.x, batch.edge_index, batch.edge_type)使用采样加载器时要注意,batch.edge_type必须跟着边一起被采样,PyG 在处理自定义字段时一般会自动传递,但如果你的数据对象里还有其它自定义字段,要确认采样器是否保留了它们。
5. 常见问题与避坑指南
5.1 edge_type的dtype和索引错误
这是我见过最多的问题。edge_type必须是torch.long,因为代码里要用它构造布尔掩码或直接索引权重矩阵。如果你在使用torch.tensor([0, 0, 1])时不指定 dtype,它默认是torch.int64,这没问题。但如果你把它转成 float 供某些模型使用,后面再用edge_type == r比较时可能不会报错,但索引self.rel_weight[r]时就会报“IndexError: tensors used as indices must be long, int, byte or bool”。遇到这个错误直接检查edge_type.dtype即可。
5.2 空关系导致的前向异常
某些小数据集里,某些关系在子图采样后可能一条边都不剩,mask.sum() == 0。我在代码里用if mask.sum() == 0: continue跳过了这种情况,但如果没有这行保护,degree()会返回一个全零张量,index_add_虽然不会崩,但梯度传播过程中可能产生 NaN。如果你在自己的实现里遇到训练 loss 突然变 NaN,优先检查是不是某个 batch 的边只覆盖了部分关系。
5.3 归一化忘写导致梯度爆炸
去掉归一化项之后,特征经过多层聚合会不断被放大,训练到十几轮 loss 就开始飞升。这个问题在关系越多的图上越明显。推荐任何图卷积实现都保留度归一化,具体用1 / deg、1 / sqrt(deg)还是对称归一化,可以按数据特点调整。RGCN 原论文用的是关系内入度归一化,这也是我代码里采用的方式。
5.4 图中存在孤立节点怎么办
如果某个节点在训练集里,但它在所有关系下都没有入边,那它的消息聚合结果为零,梯度也只来自自环项,很容易导致预测结果偏向某一类。处理办法有三种:给孤立节点加一条指向它自己的自环边;或者在特征构造阶段给所有节点加一个可学习的虚拟节点特征;最简单的做法是直接把这些节点从评估集中剔除,同时明确报告剔除原因。实际业务里孤立节点往往没有太多信息服务,但你必须知道它对结果的影响。
5.5 换成真实数据集时的适配要点
把代码迁移到 AIFB、MUTAG 等真实数据集时,有几个差异点需要处理。AIFB 是知识图谱数据,节点特征原本是 one-hot 编码的实体标识,需要先构造好特征矩阵;标签是人员所属的研究组,类别不均衡,建议按类别分层划分训练集。MUTAG 是分子图数据,一个图是一个分子,做的是图分类,这和使用 RGCN 做节点分类的思路不一样,需要加一个全局池化层把所有节点表征汇总成图表征。不要直接把节点分类代码套到图分类任务上,两者的数据组织方式完全不同。
写在后面的实战体会
我在实际项目里踩过不少坑之后,最深的一个体会是:多关系图的收益高度依赖关系定义的清晰度。关系类型的边界定义越明确、和目标任务越相关,RGCN 带来的提升越大;如果关系本身是拍脑袋分的,或者不同关系之间的语义严重重叠,模型再复杂也救不回来。拿到多关系数据后,建议先做一次关系分布统计,看看哪些关系真正支撑分类任务,哪些关系是噪声,再决定要不要全量喂给模型。另外,从普通 GCN 切到 RGCN 时,不要只换模型层,评测指标和数据划分也要一并复查一遍,否则你观察到的提升可能来自数据随机性,而不是关系建模本身。