
刚上手图神经网络的时候大多数人接触的都是同构图比如 Cora 这种论文引用网络里所有节点都是论文所有边都是“引用”这一种关系。但一到真实业务场景就会发现问题推荐系统里要同时建模用户、商品、店铺知识图谱里要处理实体和关系社交网络里连人和内容都得分类型。把这些塞进同构图的框架里要么丢掉类型信息要么特征维度不统一强拧着来特别别扭。这篇博文要解决的就是这个问题用 PyTorch GeometricPyG的 HeteroData 从零构建一个异构图全程手把手带代码。不需要你有图神经网络基础只要能看懂 Python 和简单的张量操作就行。我会从一个具体的学术网络场景入手把节点、边、类型、索引这些概念全部拆开讲明白再延伸到如何在异构图上跑 GNN 模型最后附上常见的坑和排查思路。看完这篇你不仅能徒手造出一个异构图还能知道它的内部组织方式为后续做链路预测、节点分类这些任务打好底子。1. 为什么非要搞异构图同构图装不下真实世界1.1 同构图和异构图的核心区别同构图Homogeneous Graph在数学上定义为图 G (V, E)所有节点 V 属于同一种类型所有边 E 属于同一种关系。典型例子是社交网络里的“关注”关系图节点都是用户边都是关注你可以用一个二维的邻接矩阵或者 edge_index 就把它表示得清清楚楚。异构图Heterogeneous Graph则放开了这个限制它允许节点存在多种类型边也存在多种关系类型。学术网络就是典型作者、论文、会议、机构是不同类型的节点“作者写了论文”和“论文发表在会议”是不同类型的关系。电商场景同样如此用户、商品、店铺、订单各有各的属性交互关系也五花八门。用生活化一点的方式去理解同构图像是一个班级群里面全是学生关系只有“同学”异构图像是完整的通讯录学生、老师、家长都有边可能是“教”、可能是“是家长”、也可能是“同班”。类型信息本身就是数据里非常重要的信号扔掉它等于把三分之一的语义给丢了。1.2 强行转成同构图会踩哪些坑有些人可能会想我把节点特征拼到一起、把所有边混在一起不就能用同构图工具了吗理论上可以实际操作很难受节点维度不一致是第一个坎。作者节点可能是 32 维的特征研究方向、活跃度论文节点可能是 2000 维的词袋向量会议节点可能只有几个维度的属性。强行拼接意味着要对齐维度要么做降维要么补零信息损失不说计算开销也上去了。关系语义丢失是第二个坎。假设图里既有“作者写论文”又有“论文引用论文”这两种关系对节点更新的重要性完全不同。在同构图中消息传递算子不区分边类型等于把所有关系混为一谈学出来的表征很难有区分度。PyG 从 2.0 版本开始推出 HeteroData 类专门解决这个问题。它允许你在一个数据对象中同时管理多种节点、多种边每个类型有自己独立的特征空间和索引空间消息传递时也能按类型分别处理。这是目前 PyG 处理异构图的官方推荐姿势。2. 认识 HeteroDataPyG 里的异构图数据结构2.1 HeteroData 的设计思路HeteroData 的设计可以直观理解成一个“字典套字典”的结构。外层字典的 key 是节点类型或者边类型的三元组value 是对应的特征张量或者邻接表。例如 data[paper] 取出的是论文节点的存储data[author, writes, paper] 取出的是“作者写论文”这一关系对应的边信息。一个关键设计是异构图中每种节点类型的索引是独立编号的都从 0 开始。也就是说作者节点有 0、1、2论文节点也有 0、1、2、3、4。这在异构图中是完全正常的你不要想着它们得全局统一编号。PyG 这样设计是为了让每种类型的数据操作互不干扰存储和检索都更高效。HeteroData 最核心的两个属性是 node_types 和 edge_types。node_types 是图中所有节点类型的集合edge_types 是所有边关系类型的集合。两者合起来称为 metadata在后续构建模型时会被频繁使用因为模型需要知道处理哪些类型。2.2 添加节点和边的基本规则添加节点很简单直接对类型名赋值即可data[author].x torch.randn(3, 32)这句代码的意思是为“author”类型的节点赋值一个形状为 (3, 32) 的特征矩阵3 表示有 3 个作者节点32 是特征维度。添加边用的是三元组(source_type, relation_type, target_type)data[author, writes, paper].edge_index edge_index这里要特别注意 PyG 的约定edge_index 的第一行是源节点索引第二行是目标节点索引。源节点的索引空间对应 source_type目标节点的索引空间对应 target_type两者互不混淆。节点特征可以没有边属性也可以不赋HeteroData 允许你构建只有结构没有特征的图。但在训练 GNN 时没有特征的节点类型通常需要额外的嵌入层来生成向量这一点后面会讲到。3. 从 0 到 1 实操构建一个学术网络异构图3.1 场景设定与数据准备为了让整个流程有代入感我设计一个小场景3 位作者、5 篇论文、2 个会议。作者写论文论文发表在会议。这个图虽然小但五脏俱全跑通之后换成业务数据只是改改矩阵的问题。假设原始数据长这样作者Alice、Bob、Carol分别用索引 0、1、2 表示论文P0、P1、P2、P3、P4分别用索引 0、1、2、3、4 表示会议NeurIPS、ICML分别用索引 0、1 表示关系Alice 写了 P0 和 P2Bob 写了 P1 和 P3Carol 写了 P2 和 P4P0 发表在 NeurIPSP1 发表在 ICMLP2 发表在 NeurIPSP3 发表在 ICMLP4 发表在 ICML我们给每种节点随机生成一个特征矩阵论文的特征维度设置为 16作者设为 32会议设为 8。实际项目中这些特征来自业务数据这里用随机数只是为了演示结构。3.2 创建 HeteroData 并添加节点特征先安装依赖库pip install torch torch-geometric建议把 torch 和 torch-geometric 一并安装如果网速不太好的话可以考虑用国内镜像源。装完之后直接导入import torch from torch_geometric.data import HeteroData data HeteroData() # 添加节点特征 data[author].x torch.randn(3, 32) # 3 位作者32 维特征 data[paper].x torch.randn(5, 16) # 5 篇论文16 维特征 data[venue].x torch.randn(2, 8) # 2 个会议8 维特征这里有个容易被忽略的经验节点特征矩阵的第一维必须等于实际节点数量。PyG 会通过特征矩阵的第一维来推断该类型节点的数量干涉的话后面加边时索引越界它才会报错。所以如果数据量比较小建议先把每个类型的节点数量记清楚。3.3 添加边关系和边索引接下来是最核心的部分——添加边。作者和论文之间用(author, writes, paper)表示。data[author, writes, paper].edge_index torch.tensor([ [0, 0, 1, 1, 2, 2], # 源节点作者索引 [0, 2, 1, 3, 2, 4], # 目标节点论文索引 ])这里需要一字不差地理解第一行是作者索引第二行是论文索引一一对应代表一条边。Alice0写了论文 0 和论文 2Bob1写了论文 1 和论文 3Carol2写了论文 2 和论文 4。这个对应关系要和上面设定的场景完全吻合。论文和会议之间用(paper, published_at, venue)表示data[paper, published_at, venue].edge_index torch.tensor([ [0, 1, 2, 3, 4], # 源节点论文索引 [0, 1, 0, 1, 1], # 目标节点会议索引 ])也就是 P0 和 P2 发表在 NeurIPS索引 0P1、P3、P4 发表在 ICML索引 1。运行完这段代码一个包含 3 种节点、2 种边类型的异构图就建好了整个过程比很多人想象中要简单。3.4 查看和验证数据结构构建完之后强烈建议先打印一下 data 对象确认结构是预期的那样print(data)输出会按节点类型列出每种节点的 x 张量形状按边类型列出 edge_index。可以直观地看到 author 有三个节点、paper 有五个节点、venue 有两个节点。看到这个输出说明异构图已经构建成功了。还可以进一步验证print(data.node_types) # 输出类似[author, paper, venue] print(data.edge_types) # 输出类似[(author, writes, paper), (paper, published_at, venue)] print(data.metadata()) # 输出([author, paper, venue], [(author, writes, paper), (paper, published_at, venue)])metadata 这个接口在后面搭建模型时非常常用。可以把它想象成异构图的“说明书”模型需要这份说明书来知道该初始化哪种类型的处理层。3.5 反向边和无向边的处理策略如果消息传播机制上需要“论文也能接收作者的信息”就必须显式地把反向边也加进去。PyG 不会自动帮你生成反向关系。常见做法是手动添加data[paper, written_by, author].edge_index torch.tensor([ [0, 2, 1, 3, 2, 4], [0, 0, 1, 1, 2, 2], ])这是一个和正向边完全对称的关系三元组关系名可以是任意字符串如 written_by只需要在构建模型时给它分配对应的卷积层即可。不要试图用to_undirected()来解决这个问题它在异构图上并不会自动帮你把每种类型都补上反向边实测中很容易漏掉部分类型。4. 在异构图之上训练 GNN 模型HeteroConv 实战4.1 HeteroConv 的核心原理PyG 处理异构图消息传递的核心组件是HeteroConv。它本质上是一个“卷积层分发器”对每种边类型传入一个对应的卷积算子比如 SAGEConv、GCNConv然后在前向传播时对每条边类型分别做一次消息传递最后把不同关系得到的结果聚合起来。聚合方式默认是aggrmean也可以选 sum、min、max 等。选 mean 通常比较稳健能平衡不同度数节点之间的规模差异。如果某个边类型贡献的信息重要程度不同后续可以换成Linear加权或者注意力机制这里先不做深入。SAGEConv 在异构图里使用时需要传入元组尺寸(src_dim, dst_dim)而不是单一的输入维度。因为源节点和目标节点的特征维度在异构图中经常不同这一步要特别注意。4.2 构建一个可训练的异构图网络下面代码定义了一个两层的异构图 GNN映射关系要和前面创建图时完全一致import torch.nn.functional as F from torch_geometric.nn import HeteroConv, SAGEConv class HeteroGNN(torch.nn.Module): def __init__(self, hidden_channels, out_channels, metadata): super().__init__() self.conv1 HeteroConv({ (author, writes, paper): SAGEConv((32, 16), hidden_channels), (paper, written_by, author): SAGEConv((16, 32), hidden_channels), (paper, published_at, venue): SAGEConv((16, 8), hidden_channels), (venue, published_at_rev, paper): SAGEConv((8, 16), hidden_channels), }, aggrmean) self.conv2 HeteroConv({ (author, writes, paper): SAGEConv(hidden_channels, out_channels), (paper, written_by, author): SAGEConv(hidden_channels, out_channels), (paper, published_at, venue): SAGEConv(hidden_channels, out_channels), (venue, published_at_rev, paper): SAGEConv(hidden_channels, out_channels), }, aggrmean) def forward(self, x_dict, edge_index_dict): x_dict self.conv1(x_dict, edge_index_dict) x_dict {key: F.relu(x) for key, x in x_dict.items()} x_dict self.conv2(x_dict, edge_index_dict) return x_dict第一层 conv1 里每种边类型都传入了对应的SAGEConv((src_dim, dst_dim), hidden_channels)。比如(author, writes, paper)的源维度是 32目标维度是 16所以写成SAGEConv((32, 16), hidden_channels)。反向边同理。第二层因为输入特征已经统一成了 hidden_channels 维度所以可以直接传SAGEConv(hidden_channels, out_channels)。forward 接收的x_dict是每种节点类型的特征字典edge_index_dict是每种边类型的边索引字典。HeteroConv 返回的也是字典key 就是节点类型。这种设计保证了模型完全不关心你到底有多少种类型只要 metadata 对得上就能跑。4.3 初始化模型并执行前向传播实例化模型前先确认手里的图和 metadata 一致model HeteroGNN(hidden_channels16, out_channels4, metadatadata.metadata()) optimizer torch.optim.Adam(model.parameters(), lr0.01, weight_decay5e-4)前向传播x_dict data.x_dict edge_index_dict data.edge_index_dict out model(x_dict, edge_index_dict) print(out[author].shape) # (3, 4) print(out[paper].shape) # (5, 4) print(out[venue].shape) # (2, 4)所有节点类型都有输出了。每个节点类型的输出行数等于该类型的节点数列数等于 out_channels。这就是每种节点最终的表征向量可以直接接到各种下游任务上。4.4 一个简单的半监督节点分类训练循环假设我们的任务是给论文分类只有两篇论文带标签y_paper torch.tensor([0, 1, 0, 1, 1]) # 为每篇论文定义类别 def train(): model.train() optimizer.zero_grad() out model(data.x_dict, data.edge_index_dict) loss F.cross_entropy(out[paper], y_paper) loss.backward() optimizer.step() return loss.item() for epoch in range(200): loss train() if epoch % 20 0: print(fEpoch {epoch:03d}, Loss: {loss:.4f})这个训练循环虽然简单但已经能说明异构图上训练 GNN 的基本套路取出模型的输出字典对关心的节点类型计算损失反向传播更新参数。如果你只关心论文的类别就没有必要给作者的输出单独加监督信号。5. 常见问题与排查技巧实录5.1 节点索引空间混淆异构图里最容易翻车的点就是索引空间。同一套代码里author 的 0、1、2 和 paper 的 0、1、2、3、4 是完全独立的。有人下意识把论文索引接在作者索引后面构造边的时候直接把索引加偏移量结果跑出来的边完全错乱。排查方法很简单打印每种边类型的 edge_index检查源节点索引是否在源类型的节点数量范围内目标节点索引是否在目标类型的范围内。如果越界PyG 报错的提示会直接告诉你哪条边越界了根据报错去定位修改即可。5.2 边方向搞反导致特征维度不匹配SAGEConv 初始化时写了(32, 16)如果边方向写反了变成(paper, writes, author)它会默认源是 paper目标是 author维度变成了(16, 32)和模型层声明的不一致训练时直接报维度错误。这里分享一个检查技巧碰到维度报错时先打印 data.metadata()对照边的三元组和模型中字典的 key 是否完全一致。很多人纠结半天原来是手滑把字符串写错了比如 written_by 在数据里是 written_by在模型里变成了 write_byPyG 会因为找不到对应边类型而报 KeyError。5.3 没有特征的节点类型怎么办实际业务中经常有节点没有特征比如会议节点可能只有名字没有数值向量。但 GNN 消息传递是要求每个节点都有初始表征的光有边没有特征等于从零开始模型完全学不动。一个非常实用的做法是为这种类型单独添加一个可学习的嵌入层。先给节点编号再通过 Embedding 生成特征self.venue_emb torch.nn.Embedding(num_venues, embedding_dim) data[venue].node_id torch.arange(num_venues)传播时对没有 x 的节点类型直接取嵌入层的输出当 x。这个技巧在处理推荐系统场景时尤其常用比如冷门商品没有画像特征就靠一个可学习向量在训练中慢慢学出来。5.4 to_homogeneous() 的维度陷阱PyG 提供了to_homogeneous()方法可以把异构图转成同构图但默认情况下它只是把节点和边简单拼接不同节点类型的特征维度不一致时转换后的 x 会为空导致后续模型直接崩。即使维度一致转换后节点类型信息也会丢失除非你设置node_type相关参数但处理起来很麻烦。我的建议是除非万不得已要做复杂的图级可视化否则不要轻易把异构图转同构。搞可视化的话可以用 NetworkX 转换但图规模一大NetworkX 的布局计算会非常慢。5.5 元路径提取不能一步到位想在异构图里用 metapath 方法比如 author-paper-author 这种路径语义PyG 目前没有内置一个现成接口直接提取任意元路径的边。你需要手动遍历 edge_index用集合操作完成路径拼接。这里分享一个简化版思路假设要提取 author-paper-author 的元路径本质是找到两篇论文被同一位作者写作的关系。先把 author-writes-paper 的边索引拆出来按作者分组如果同一个作者出现在多篇论文里那么这些论文之间就产生了一条 author-paper-author 的关系。看着不复杂但在数据量大时需要谨慎控制内存因为中间结果的笛卡尔积会膨胀得很快。5.6 可视化检查结构构建完异构图后如果结构复杂强烈建议快速可视化确认。PyG 提供了一个简单的转 NetworkX 方式from torch_geometric.utils import to_networkx # 这个方法对异构图支持有限通常需要先转同构图 # 更可靠的做法是手动遍历每种边类型用 networkx 添加边手动遍历方式虽然代码多一点但对异构图的控制力最强能确保每条边的关系类型都被标清楚。用 NetworkX 画完图后一眼就能看出有没有缺边、方向错乱等问题。最后再分享一点实际体会用了这么久 PyG 的 HeteroData我最大的感受是它把异构图的“数据结构”问题解决得很彻底你有多少种节点、多少种边它就帮你分门别类地存好模型侧再用 HeteroConv 对应处理整个代码结构和图结构有一种天然的对齐感。但正因为这种对齐代码冗余度会变高——每加一种边类型模型里就得加对应的卷积层。所以实际项目中建议写一个辅助函数根据 metadata 里的 edge_types 自动生成卷积层字典避免手写太多重复代码。另外刚开始学习异构图的人可以先别急着上大模型、大数据集就按这篇博文的方式用三五个节点、几条边把工具链跑熟再去读官方的 heterogeneous 示例你会发现那些代码一点都不神秘。结构理解了剩下的就是工程细节问题。如果在构建或训练过程中遇到报错欢迎按上面的排查思路对照检查大多数问题都出在索引空间、边方向这些基础环节上。