图神经网络学习笔记6:异构图
简单比喻:同构图 (Homogeneous) = 纯牛奶(里面只有一种东西:牛奶分子)。异构图/多部图 (Heterogeneous) = 珍珠奶茶(里面有:牛奶、珍珠、椰果)场景类比:场景 A:同构图 (前面1-5做的)例子:Cora 引用网络。节点:全是论文。边:只有一种关系:引用。特点:因为大家都是论文,所以大家的特征维度都一样(比如都是 1433 维),地位也平等。GNN 处理方式:大家一
1.什么是异构图
简单比喻:
-
同构图 (Homogeneous) = 纯牛奶(里面只有一种东西:牛奶分子)。
-
异构图/多部图 (Heterogeneous) = 珍珠奶茶(里面有:牛奶、珍珠、椰果)
场景类比:
场景 A:同构图 (前面1-5做的)
-
例子:Cora 引用网络。
-
节点:全是论文。
-
边:只有一种关系:引用。
-
特点:因为大家都是论文,所以大家的特征维度都一样(比如都是 1433 维),地位也平等。
-
GNN 处理方式:大家一起去平均,没问题。
-
场景 B:异构图 (本节内容)
-
例子:IMDB 电影数据库。
-
节点:这里有三种东西:演员,电影,导演
-
边:关系复杂起来:
-
演员 “出演” 电影 (Actor -> acts_in -> Movie)
-
导演 “执导” 电影 (Director -> directs -> Movie)
-
电影 “续集” 电影 (Movie -> sequel -> Movie)
-
这就是异构图:图里包含不同类型的节点,和不同类型的边。
2.step1:手搓一个异构图
代码:
依然使用kaggle notebook
import torch
from torch_geometric.data import HeteroData
# 1. 创建“杯子”
data = HeteroData()
# ==========================================
# 2. 放入节点 (Nodes) —— 加料
# ==========================================
# 假设我们有: 100部电影, 50个演员, 20个导演
# 电影的特征 (比如: 预算, 时长, 年份) -> 64维
data['movie'].x = torch.randn(100, 64)
# 演员的特征 (比如: 年龄, 身高, 颜值) -> 32维
# 【重点】注意!演员的特征维度(32)和电影(64)可以不一样!
# 这在同构图里是不可能的,但在异构图里没问题。
data['actor'].x = torch.randn(50, 32)
# 导演的特征 (比如: 获奖数, 执导年限) -> 32维
data['director'].x = torch.randn(20, 32)
# ==========================================
# 3. 放入边 (Edges) —— 混合
# ==========================================
# 边的定义变成了 "三元组" (Source, Relation, Target)
# 关系 A: 演员 -> 出演 -> 电影
# 随机生成一些连接 (这里生成 200 条边)
# row: 演员ID (0-49), col: 电影ID (0-99)
edge_index_actor_movie = torch.randint(0, 50, (2, 200))
edge_index_actor_movie[1] = torch.randint(0, 100, (200,))
data['actor', 'acts_in', 'movie'].edge_index = edge_index_actor_movie
# 关系 B: 导演 -> 执导 -> 电影
# 生成 100 条边
edge_index_director_movie = torch.randint(0, 20, (2, 100))
edge_index_director_movie[1] = torch.randint(0, 100, (100,))
data['director', 'directs', 'movie'].edge_index = edge_index_director_movie
print("🧋 珍珠奶茶 (异构图) 制作完成!")
print(data)
输出:
3.step2:搭建模型
代码:
#模型搭建
from torch_geometric.nn import SAGEConv, HeteroConv, Linear
class HeteroGNN(torch.nn.Module):
def __init__(self, hidden_channels, out_channels):
super().__init__()
# HeteroConv 是一个 "容器"
# 里面装了一个字典,定义了每种关系用什么 GNN 去处理
self.conv1 = HeteroConv({
# 这里的 SAGEConv((-1, -1), ...) 意思是不强制输入维度,自动推断
# 逻辑:演员(32) -> 电影(64)。SAGE 会自动把 32 投影到 64
('actor', 'acts_in', 'movie'): SAGEConv((-1, -1), hidden_channels),
# 逻辑:导演(32) -> 电影(64)
('director', 'directs', 'movie'): SAGEConv((-1, -1), hidden_channels),
# 注意:如果还需要电影自己更新自己 (比如电影互相有边),也要在这里加
}, aggr='sum') # aggr='sum': 两位大厨的结果加起来
self.conv2 = HeteroConv({
('actor', 'acts_in', 'movie'): SAGEConv((-1, -1), out_channels),
('director', 'directs', 'movie'): SAGEConv((-1, -1), out_channels),
}, aggr='sum')
def forward(self, x_dict, edge_index_dict):
# === Layer 1 ===
x_dict_out1 = self.conv1(x_dict, edge_index_dict)
x_dict_out1 = {key: x.relu() for key, x in x_dict_out1.items()}
# === 关键修复:保留食材 ===
# 第一层输出的 x_dict_out1 只有 {'movie': ...}
# 我们需要把它和原始的 x_dict 合并,确保 'actor' 和 'director' 还在
# 这样第二层卷积才能找到输入源
x_dict_layer2 = x_dict.copy() # 复制一份原始菜单(包含 actor, director, movie)
x_dict_layer2.update(x_dict_out1) # 用第一层煮好的新 movie 覆盖旧 movie
# 现在 x_dict_layer2 里面有:
# 1. 原始的 actor (Layer 2 需要作为源)
# 2. 原始的 director (Layer 2 需要作为源)
# 3. 升级后的 movie (Layer 2 需要作为目标)
# === Layer 2 ===
x_dict_out2 = self.conv2(x_dict_layer2, edge_index_dict)
return x_dict_out2
# 初始化模型
# 假设我们要把电影分类成 3 种类型 (动作, 爱情, 科幻)
model = HeteroGNN(hidden_channels=64, out_channels=3)
print("\n🚀 模型搭建完毕!结构如下:")
print(model)
是不是说 演员维度 + 导演维度 一定要等于 电影维度?
完全不需要。
GNN 内部有一个权重矩阵W,形状是 [32, 64],它把 32 维的演员特征,强制乘变成 64 维。
演员可以是 10 维,导演可以是 1000 维,电影可以是 5 维,随便设置。
唯一的要求是两位大厨的输出维一定要相同,因为最后要把这两盘菜倒进同一个碗里。
为什么要有两个大厨
-
比喻:
-
大厨 A (处理演员):他是个“切肉师傅”。他知道处理演员数据需要“切片”。
-
大厨 B (处理导演):他是个“煲汤师傅”。他知道处理导演数据需要“慢炖”。
-
如果只有一个大厨,他要是用切肉的手法去处理汤,或者用煲汤的手法去处理肉,结果就乱套了。
-
数学层面:
-
演员特征里的“第 1 位”代表“颜值”。
-
导演特征里的“第 1 位”代表“获奖数”。
-
如果共用一个矩阵 W,模型就会把“颜值”和“获奖数”混为一谈,用同样的系数去乘它们。
-
异构图的精髓就在于:针对每种关系,我们都训练一套独立的参数(专属大厨)来专门提取这种关系的特征。
顺序:
在代码里,它们其实是并行发生的(或者逻辑上独立的)。
-
大厨 A 算出:演员给电影贡献了多少分。
-
大厨 B 算出:导演给电影贡献了多少分。
-
最后一步 (Aggregate):电影的总得分 = 演员分 + 导演分。
为什么还用到了 SAGEConv
SAGEConv 就是在上一章GraphSAGE里学过的,在这里它被雇佣了。
这里有一个层级关系:
-
HeteroConv (包工头/容器):
-
它的作用只是管理。它手里拿着一个清单(字典),上面写着:“对于演员-电影这条边,请 A 工人去干;对于导演-电影这条边,请 B 工人去干。”
-
它自己不会算卷积,它只负责分配任务和最后汇总。
-
-
SAGEConv (工人/具体的算法):
-
这是真正干活的。你之前学过 GraphSAGE,它擅长**“聚合邻居特征并取平均”**。
-
在这里,我们觉得 GraphSAGE 的算法(Sampling + Mean Aggregation)很好用,所以我们决定:每一条边内部的特征提取,都沿用 GraphSAGE 的算法。
-
能不能换? 可以:
-
如果“注意力机制”更好,可以把里面的
SAGEConv换成GATConv。 -
如果最简单的“加权求和”就够了,可以换成
GCNConv。
4.step3:运行模型
代码
# 运行模型
with torch.no_grad():
output_dict = model(data.x_dict, data.edge_index_dict)
print("\n✨ 输出结果:")
# 我们只关心 "movie" 的结果,因为所有信息都聚合并更新到了 movie 上
movie_output = output_dict['movie']
print(f"电影节点的输出维度: {movie_output.shape}")
# 预期: [100, 3] -> 100部电影,每部3个分类概率
5.补充内容
1.HAN-引入attention
这是 GAT 在异构图上的进化版。它引入了 两层注意力:
-
节点级注意力:
在“演员→电影”这条边里,模型会自动学习:莱昂纳多权重 0.9,路人甲权重 0.01。 -
语义级注意力:
-
模型会思考:对于判断电影类型,是“演员”更重要,还是“导演”更重要?
-
也许判断“动作片”看演员,判断“文艺片”看导演。
-
代码:
把step2代码里的 SAGEConv 换成 GATConv。
#替换step2的cell
import torch
import torch.nn.functional as F
from torch_geometric.nn import HeteroConv, GATConv
class HeteroGNN(torch.nn.Module):
def __init__(self, hidden_channels, out_channels):
super().__init__()
# === 第一层:多头 GAT ===
self.conv1 = HeteroConv({
# GATConv 参数解析:
# 1. (-1, -1): 自动推断输入维度 (演员/导演 -> 电影)
# 2. hidden_channels: 每个头的输出维度
# 3. heads=2: 使用 2 个注意力头 (你可以改成 4 或 8)
# 4. add_self_loops=False: 【关键】异构二部图必须关掉自环
('actor', 'acts_in', 'movie'): GATConv((-1, -1), hidden_channels,
heads=2, add_self_loops=False),
('director', 'directs', 'movie'): GATConv((-1, -1), hidden_channels,
heads=2, add_self_loops=False),
}, aggr='sum')
# === 第二层:输出层 ===
self.conv2 = HeteroConv({
# 注意:这里的输入维度不需要手动算,(-1, -1) 会自动处理上一层拼接后的维度
# 重点:concat=False
# 我们希望最终输出就是 out_channels (3类),而不是 out_channels * heads
('actor', 'acts_in', 'movie'): GATConv((-1, -1), out_channels,
heads=1, concat=False, add_self_loops=False),
('director', 'directs', 'movie'): GATConv((-1, -1), out_channels,
heads=1, concat=False, add_self_loops=False),
}, aggr='sum')
def forward(self, x_dict, edge_index_dict):
# === Layer 1 ===
x_dict_out1 = self.conv1(x_dict, edge_index_dict)
# GAT 论文推荐使用 ELU 激活函数,而不是 ReLU
x_dict_out1 = {key: F.elu(x) for key, x in x_dict_out1.items()}
# === "补货"逻辑 (解决之前演员导演丢失的报错) ===
x_dict_layer2 = x_dict.copy()
x_dict_layer2.update(x_dict_out1)
# === Layer 2 ===
x_dict_out2 = self.conv2(x_dict_layer2, edge_index_dict)
return x_dict_out2
#初始化模型
# hidden_channels=64: 中间层维度
# out_channels=3: 假设我们要把电影分 3 类
model = HeteroGNN(hidden_channels=64, out_channels=3)
2.元路径-寻找隐形关系
有时候,直接关系看不出什么,间接关系才是核心。
例子:演员 A - 电影 - 演员 B。
这条路径叫“共演”。如果 A 和 B 经常一起演戏,他们之间就有一条隐形的强连接。
我们可以在图里人为地添加这条“虚拟边”,让模型学习“圈子文化”。
干嘛来的呢?比如可以预测演员得奖概率。
没有元路径时,模型只能看到:“莱昂纳多演了《泰坦尼克号》”。它不知道莱昂纳多和谁搭档过,除非它先把信息传给电影,电影再传回给另一个演员(路径太长,信号会衰减)。
有了元路径时,模型直接看到了:“莱昂纳多 和 另一个演员A是共演关系,如果一个演员经常和“奥斯卡影帝”一起演戏(Co-star 边很多),那么大概率他自己的演技也不差,或者他在一个很高端的圈子里。
所以,元路径的作用就是缩短信息传递的距离,把隐形的社交关系显式地画出来。
代码实现:
不同于HAN只用改step2的类和forward,元路径麻烦一些。
核心:把“元路径”当成一条“新造出来的边”。
比如:演员 -> 电影 -> 演员(共演关系)。
-
数据层面:你手动算出 A 和 B 是共演,直接在
data里加一条新边('actor', 'co_star', 'actor')。 -
模型层面:给这条新边专门配一个
GATConv大厨。
#数据准备
# 必须先安装 (如果你还没装)
!pip install -q torch_geometric
import torch
from torch_geometric.data import HeteroData
# ==========================================
# 1. 创建“容器”
# ==========================================
data = HeteroData()
# ==========================================
# 2. 放入节点 (Nodes) —— 加料
# ==========================================
num_movies = 100
num_actors = 50
num_directors = 20
# 电影特征 (100, 64)
data['movie'].x = torch.randn(num_movies, 64)
# 演员特征 (50, 32)
data['actor'].x = torch.randn(num_actors, 32)
# 导演特征 (20, 32)
data['director'].x = torch.randn(num_directors, 32)
# ==========================================
# 3. 放入边 (Edges) —— 混合
# ==========================================
# --- 关系 A: 演员 -> 出演 -> 电影 ---
# 生成 200 条随机边
edge_index_actor_movie = torch.randint(0, num_actors, (2, 200)) # 随机生成
edge_index_actor_movie[1] = torch.randint(0, num_movies, (200,)) # 修正第二行是电影ID
data['actor', 'acts_in', 'movie'].edge_index = edge_index_actor_movie
# --- 关系 B: 导演 -> 执导 -> 电影 ---
edge_index_director_movie = torch.randint(0, num_directors, (2, 100))
edge_index_director_movie[1] = torch.randint(0, num_movies, (100,))
data['director', 'directs', 'movie'].edge_index = edge_index_director_movie
# ==========================================
# 4. 【这里开始改代码】加入元路径 (Metapath): Actor-Movie-Actor
# ==========================================
print("正在计算共演关系 (Co-star)...")
# 步骤 1: 把 edge_index 变成稀疏矩阵 (Sparse Matrix)
# 这是一个 (Actor x Movie) 的矩阵,也就是 [50, 100]
# 值为 1 代表演过,0 代表没演过
adj = torch.sparse_coo_tensor(
edge_index_actor_movie,
torch.ones(edge_index_actor_movie.shape[1]), # 边的权重全设为 1
(num_actors, num_movies) # 矩阵大小
)
# 步骤 2: 矩阵乘法 (Magic Happens Here!)
# (Actor x Movie) 乘以 (Movie x Actor) = (Actor x Actor)
# 结果矩阵中,如果 (i, j) 位置的值 > 0,说明演员 i 和演员 j 合作过
# t() 是转置的意思
co_star_matrix = torch.sparse.mm(adj, adj.t())
# 步骤 3: 把矩阵变回 edge_index
# coalesced() 是为了整理稀疏矩阵的内部结构,indices() 提取坐标
co_star_edge_index = co_star_matrix.coalesce().indices()
# 步骤 4: 去掉自环 (Self-loops)
# 矩阵乘法会算出 "莱昂纳多和莱昂纳多合作过",这没意义,我们要去掉
row, col = co_star_edge_index
mask = row != col # 只保留 row 不等于 col 的边
data['actor', 'co_star', 'actor'].edge_index = co_star_edge_index[:, mask]
print("✅ 异构图制作完成!")
print("-" * 30)
print(data)
print("-" * 30)
# 验证一下
print(f"生成的共演边数量: {data['actor', 'co_star', 'actor'].edge_index.shape[1]}")
#类
#还用 GATConv。新加一条边就行。
class HeteroGNN(torch.nn.Module):
def __init__(self, hidden_channels, out_channels):
super().__init__()
# === Layer 1 ===
self.conv1 = HeteroConv({
# 1. 原有的:演员 -> 电影
('actor', 'acts_in', 'movie'): GATConv((-1, -1), hidden_channels, heads=2, add_self_loops=False),
# 2. 原有的:导演 -> 电影
('director', 'directs', 'movie'): GATConv((-1, -1), hidden_channels, heads=2, add_self_loops=False),
# 3. 【新增元路径】:演员 <-> 演员 (Co-star)
# 逻辑:让演员通过共演关系,聚合其他演员的特征,更新自己!
# 注意:源是 actor,目标也是 actor,所以 add_self_loops=True 是安全的,
# 但为了严谨通常 Metapath 也可以设为 False,看具体需求。
('actor', 'co_star', 'actor'): GATConv((-1, -1), hidden_channels, heads=2, add_self_loops=False),
}, aggr='sum')
# === Layer 2 ===
self.conv2 = HeteroConv({
# 原有的边...
('actor', 'acts_in', 'movie'): GATConv((-1, -1), out_channels, heads=1, concat=False, add_self_loops=False),
('director', 'directs', 'movie'): GATConv((-1, -1), out_channels, heads=1, concat=False, add_self_loops=False),
# 【新增元路径】
# 如果你想让演员分类,这里可以加。
# 但如果你只是想给电影分类,第二层甚至可以不加这条边!
# 这里假设我们只用元路径来增强第一层的特征提取。
}, aggr='sum')
#forward调整更新目标。
#以前只有 Movie 被更新。
#现在因为有一条边是 actor -> actor,所以 Actor 的特征也会被更新了
#forward函数记得贴在class名下,别忘了缩进
def forward(self, x_dict, edge_index_dict):
# === Layer 1 ===
# 这里 HeteroConv 会自动处理那条 'co_star' 的边
# 结果:x_dict_out1 里面不仅有 'movie',现在也有 'actor' 了!(因为 actor 也是目标节点了)
x_dict_out1 = self.conv1(x_dict, edge_index_dict)
x_dict_out1 = {key: F.elu(x) for key, x in x_dict_out1.items()}
# === 补货逻辑 (Update) ===
# 这一步非常重要!
# 以前:x_dict_out1 只有 movie,我们把原始 actor 补回去。
# 现在:x_dict_out1 已经有了“升级版”的 actor (通过元路径学到了东西)。
# 所以 update 的时候,新 actor 会覆盖旧 actor。这是我们想要的!
x_dict_layer2 = x_dict.copy()
x_dict_layer2.update(x_dict_out1)
# 现在 x_dict_layer2 里:
# - movie: 1级进化 (吸收了演员+导演)
# - actor: 1级进化 (吸收了共演伙伴的信息) <--- 元路径的功劳
# - director: 原始版 (因为没给他加元路径)
# === Layer 2 ===
x_dict_out2 = self.conv2(x_dict_layer2, edge_index_dict)
return x_dict_out2
#记得粘贴上初始化模型
3.反向边的“闭环逻辑”
信息双向流动,食材(演员和导演)的信息也更新了。
例子:
-
初始状态 (Layer 0):
-
莱昂纳多 (Actor):特征可能只是
[48岁, 男, 183cm]。这只是物理属性。 -
泰坦尼克号 (Movie):特征可能只是
[1997年, 2亿预算]。这只是商业属性。 -
此时,莱昂纳多只是个普通的中年男子,泰坦尼克号只是一部烧钱的旧片子。
-
-
没有反向边 (单向流动:Actor → Movie):
-
Movie 变强了:泰坦尼克号吸收了莱昂纳多的信息,它知道:“哦,我是一部由 183cm 中年男子主演的电影”。
-
Actor 还是傻的:莱昂纳多的特征依然是
[48岁, 男, 183cm]。他根本不知道自己演过《泰坦尼克号》,也不知道自己是“影帝”。他的身份没有因为他的作品而得到升华。
-
-
有反向边 (闭环逻辑:Actor
Movie):
-
Layer 1 (Ping-Pong 回合 1):电影吸收演员信息。
-
Layer 2 (Ping-Pong 回合 2):关键来了! 莱昂纳多吸收了电影的信息。
-
他的特征变成了:
[48岁, 男, 183cm] + [主演过2亿预算的大片]。 -
提升:他的“行业地位”和“上下文信息”。他不再是一个物理人,而是一个“演过大片的演员”。
-
-
Layer 3 (Ping-Pong 回合 3):电影再次吸收这个“升级版”莱昂纳多的信息。
-
电影:“哇,主演我的不是普通人,而是‘演过大片的资深演员’!”
-
电影的特征再次提升,分类更加准确。
-
-
总结:
“闭环”提升的是节点对自己身份的认知。
如果不双向流动,源节点(演员)永远是静态的背景板;双向流动后,源节点变成了动态的参与者,它的特征会随着网络结构不断进化。
代码:
#数据准备
#原来的 edge_index 翻转一下(Row 变 Col,Col 变 Row)
import torch
from torch_geometric.data import HeteroData
# 此处省略1. 创建“容器”,2. 放入节点, 3. 放入边的代码
#在放入边的最后加入反向边
print("正在添加反向边 (Reverse Edges)...")
# === 1. 演员 -> 电影 的反向:电影 -> 演员 ===
# 原来的边: data['actor', 'acts_in', 'movie'].edge_index (形状 [2, 200])
# 翻转逻辑: edge_index[[1, 0]] 也就是把第一行和第二行对调
data['movie', 'rev_acts_in', 'actor'].edge_index = data['actor', 'acts_in', 'movie'].edge_index[[1, 0]]
# === 2. 导演 -> 电影 的反向:电影 -> 导演 ===
data['movie', 'rev_directs', 'director'].edge_index = data['director', 'directs', 'movie'].edge_index[[1, 0]]
print("✅ 反向边添加完成!现在的图结构:")
print(data)
# 你会发现多了两行 edge_type
#模型构建
#现在图里有了 4 种关系(2正 + 2反),我们需要在 HeteroGNN 里处理它们
#有了反向边,所有节点(演员、导演、电影)都会作为“目标节点”被更新
#不再需要那个手动的 x_dict.update(...) 补货逻辑了
from torch_geometric.nn import HeteroConv, GATConv, SAGEConv
import torch.nn.functional as F
class HeteroGNN_BiDirectional(torch.nn.Module):
def __init__(self, hidden_channels, out_channels):
super().__init__()
# === Layer 1: 所有边都要参与 ===
self.conv1 = HeteroConv({
# 正向:演员/导演 -> 电影
('actor', 'acts_in', 'movie'): GATConv((-1, -1), hidden_channels, add_self_loops=False),
('director', 'directs', 'movie'): GATConv((-1, -1), hidden_channels, add_self_loops=False),
# 【新增】反向:电影 -> 演员/导演
# 逻辑:演员也要听听电影怎么说,提升自己的特征
('movie', 'rev_acts_in', 'actor'): GATConv((-1, -1), hidden_channels, add_self_loops=False),
('movie', 'rev_directs', 'director'): GATConv((-1, -1), hidden_channels, add_self_loops=False),
}, aggr='sum')
# === Layer 2: 继续所有边参与 (闭环形成) ===
self.conv2 = HeteroConv({
# 正向
('actor', 'acts_in', 'movie'): GATConv((-1, -1), out_channels, add_self_loops=False),
('director', 'directs', 'movie'): GATConv((-1, -1), out_channels, add_self_loops=False),
# 反向 (虽然我们最后只分类电影,但保持反向边可以让网络更加稳健,
# 或者如果你想顺便给演员分类,这里就是必须的)
('movie', 'rev_acts_in', 'actor'): GATConv((-1, -1), out_channels, add_self_loops=False),
('movie', 'rev_directs', 'director'): GATConv((-1, -1), out_channels, add_self_loops=False),
}, aggr='sum')
def forward(self, x_dict, edge_index_dict):
# === Layer 1 ===
# PyG 很聪明:因为你定义了指向 actor 的卷积,
# 所以 x_dict_out1 里面会自动包含更新后的 'actor' 和 'director'!
# 我们再也不用手动 copy/update 了。
x_dict_out1 = self.conv1(x_dict, edge_index_dict)
x_dict_out1 = {key: F.elu(x) for key, x in x_dict_out1.items()}
# === Layer 2 ===
# 直接把全是新特征的字典扔进去
x_dict_out2 = self.conv2(x_dict_out1, edge_index_dict)
return x_dict_out2
# 初始化并测试
model = HeteroGNN_BiDirectional(hidden_channels=64, out_channels=3)
# 跑一次看看
with torch.no_grad():
out = model(data.x_dict, data.edge_index_dict)
print("\n✨ 闭环逻辑测试结果:")
print(f"电影特征维度: {out['movie'].shape}")
print(f"演员特征维度: {out['actor'].shape} (演员也被更新了)")
print(f"导演特征维度: {out['director'].shape}")
运行模型部分同原来一样
更多推荐


所有评论(0)