【图神经网络】DGL实战:异构图神经网络在推荐系统中的应用
1. 从零开始:为什么推荐系统需要异构图?
如果你做过推荐系统,或者哪怕只是用过一些App,你肯定遇到过这样的场景:淘宝猜你喜欢、抖音的“下一个视频”、网易云音乐的每日推荐。这些系统是怎么知道你可能喜欢什么的?传统的方法,比如协同过滤,简单来说就是“喜欢A物品的人也喜欢B物品,所以推荐给你B”。这方法在早期很有效,但问题也很明显:它把用户和物品都当成了一个个孤立的ID,忽略了用户本身丰富的属性(年龄、性别、浏览历史)和物品复杂的特征(品类、价格、描述文本)。
这就好比给你介绍对象,媒人只告诉你“之前和TA相亲过的人都挺满意”,却完全不提对方的性格、爱好、职业。你心里肯定没底。推荐系统也一样,我们需要更丰富的“信息”来做判断。
这时候,图(Graph) 就登场了。我们可以把用户和物品都看作图上的“节点”,用户点击、购买、评分这些行为就是连接他们的“边”。这样一来,整个推荐系统的数据就构成了一张巨大的网络图。但问题又来了,用户节点和物品节点是两种完全不同的东西,它们的特征维度、含义天差地别。比如用户特征可能是[年龄=25, 城市=北京],而物品特征可能是[品类=手机, 价格=3999]。用传统的、只处理单一类型节点的同构图神经网络(GNN)来处理,就像让一个只会说中文的人去理解法文和德文混在一起的文档,非常别扭。
所以,我们需要异构图神经网络(Heterogeneous Graph Neural Network)。它就像个多语言专家,能同时理解“用户”和“商品”这两种不同的“语言”,并学习它们之间复杂的交互模式。DGL(Deep Graph Library)是目前最流行的图神经网络框架之一,它对异构图的原生支持做得非常好,让我们可以相对轻松地搭建和训练这类模型。
我刚开始接触异构图时,也觉得概念有点绕。但后来想明白了,其实它就是把现实世界中复杂的关系网络,用一种更贴近本质的方式建模。一旦用图把数据组织起来,很多问题就变得直观了。比如,要预测用户会不会买某个商品,本质上就是预测图上这两个节点之间会不会产生一条新的“购买”边,这就是典型的链接预测任务。而异构图神经网络,就是用来解决这类任务的利器。
2. 动手之前:用DGL构建你的第一张异构图
理论说再多,不如动手跑一行代码。在开始构建复杂的模型之前,我们得先把数据“图化”。DGL提供了非常直观的接口来创建和处理异构图,咱们一步步来。
首先,假设我们有一个简单的电影推荐场景,有三种节点:用户、电影、导演;有两种边:用户-评分->电影、电影-由->导演。评分边可能还带有属性,比如分数(1-5分)。
2.1 创建异构图对象
在DGL里,创建异构图的核心是 dgl.heterograph 函数,它接受一个字典来定义不同类型的边。这是最关键的一步,我刚开始也在这里卡过。
import dgl
import torch
# 定义图结构数据
graph_data = {
# 边类型: (源节点类型, 边类型, 目标节点类型)
('用户', '评分', '电影'): (torch.tensor([0, 0, 1, 2]), torch.tensor([0, 1, 1, 2])), # 用户0评分了电影0和1,用户1评分了电影1,用户2评分了电影2
('电影', '由', '导演'): (torch.tensor([0, 1, 2]), torch.tensor([0, 1, 0])), # 电影0由导演0执导,电影1由导演1执导,电影2由导演0执导
}
# 创建异构图
hetero_graph = dgl.heterograph(graph_data)
print(hetero_graph)
运行这段代码,你会看到类似这样的输出:
Graph(num_nodes={'用户': 3, '电影': 3, '导演': 2},
num_edges={('用户', '评分', '电影'): 4, ('电影', '由', '导演'): 3},
metagraph=[('用户', '电影', '评分'), ('电影', '导演', '由')])
这告诉我们,图里有3类节点,数量分别是3、3、2;有2类边,数量分别是4和3。metagraph 展示了图的元结构,清晰明了。
2.2 为节点和边添加特征
光有结构还不够,我们需要把丰富的特征信息附加上去。DGL允许我们为不同类型的节点和边分别设置特征。
# 为用户节点添加特征,假设每个用户有2个特征(例如:年龄、活跃度)
hetero_graph.nodes['用户'].data['feat'] = torch.randn(3, 2)
# 为电影节点添加特征,假设每个电影有3个特征(例如:类型编码、上映年份、预算)
hetero_graph.nodes['电影'].data['feat'] = torch.randn(3, 3)
# 为导演节点添加特征,假设每个导演有2个特征(例如:获奖次数、从业年限)
hetero_graph.nodes['导演'].data['feat'] = torch.randn(2, 2)
# 为‘评分’边添加特征,例如评分分数
hetero_graph.edges['评分'].data['rating'] = torch.tensor([5.0, 4.0, 3.0, 2.0])
print("用户特征:", hetero_graph.nodes['用户'].data['feat'])
print("评分边特征:", hetero_graph.edges['评分'].data['rating'])
这里我用了随机生成的张量来模拟特征,真实项目中,这些特征来自你的数据预处理,比如用户画像的向量、物品的嵌入向量或者文本特征的编码。为不同类型的节点分配不同维度的特征,这正是异构图灵活和强大的地方。你可以根据数据的实际情况自由设计,比如用BERT编码电影简介作为电影节点的初始特征。
2.3 异构图的基本操作
创建好图之后,我们经常需要查询和操作它。DGL的API设计得很人性化。
# 1. 获取特定类型的子图
user_movie_subgraph = hetero_graph['用户', '评分', '电影']
print(f"用户-电影子图的边数: {user_movie_subgraph.num_edges()}")
# 2. 获取某个节点的邻居(非常重要!)
# 获取电影1(索引为1)的所有评分它的用户
rating_users = hetero_graph.predecessors(1, etype=('用户', '评分', '电影'))
print(f"为电影1评分的用户有: {rating_users}")
# 3. 获取某类边的所有边数据
all_ratings = hetero_graph.edges['评分'].data['rating']
print(f"所有评分: {all_ratings}")
这些操作是后续消息传递的基础。理解如何从图中获取你需要的信息,是写好异构图神经网络代码的关键。我建议在Jupyter Notebook里多试试这些操作,直观地感受图的结构,这比干看文档有效得多。
3. 模型实战:RGCN与GCMC的原理与DGL实现
数据准备好了,接下来就是模型部分。针对异构图,有两个非常经典且实用的模型:RGCN 和 GCMC。它们在DGL中都有很好的支持,我们来看看怎么用。
3.1 RGCN:处理多关系图的通用利器
RGCN(Relational Graph Convolutional Network)可以看作是经典GCN在异构图的直接扩展。它的核心思想很简单:不同类型的边,使用不同的权重矩阵进行消息传递。
想象一下,在一个社交网络里,“关注”关系和“同事”关系传递的信息重要性肯定不同。RGCN就为每种关系(边类型)都学一套独立的参数。它的消息传递公式可以简化为:对于每个节点,它会分别聚合来自每种类型邻居的信息,每种类型的信息用各自的变换矩阵处理,然后汇总起来更新节点自身。
在DGL中,实现一个RGCN层异常简单,这得益于它高度模块化的设计。
import torch.nn as nn
import torch.nn.functional as F
import dgl.nn as dglnn
class HeteroRGCNLayer(nn.Module):
def __init__(self, in_feats, out_feats, etypes):
super(HeteroRGCNLayer, self).__init__()
# 为每一种边类型创建一个独立的权重矩阵
self.weight = nn.ModuleDict({
etype: nn.Linear(in_feats, out_feats) for etype in etypes
})
def forward(self, g, feat_dict):
"""
g: 异构图
feat_dict: 字典,键为节点类型,值为该类型所有节点的特征张量
"""
new_feat_dict = {}
for ntype in g.ntypes: # 遍历图中所有节点类型
# 初始化该类型节点的消息聚合结果
aggregated = torch.zeros(g.num_nodes(ntype), self.weight[list(self.weight.keys())[0]].out_features).to(feat_dict[ntype].device)
# 遍历所有可能指向该类型节点ntype的边类型
for etype in g.canonical_etypes:
stype, _, dtype = etype
if dtype != ntype:
continue # 只处理目标节点是当前节点类型的边
# 获取源节点特征
src_feat = feat_dict[stype]
# 获取该类型边的所有边,并计算消息
# 这里简化了,实际RGCN会对邻居消息做归一化。DGL有更高效的API `dgl.nn.HeteroGraphConv`
subgraph = g[etype]
# 模拟消息传递:每个源节点通过对应边类型的权重矩阵变换特征
# 注意:这里为了演示原理,使用了循环,实际应用请使用DGL内置模块,效率更高
with g.local_scope():
g.nodes[stype].data['h'] = src_feat
g.apply_edges(lambda edges: {'m': self.weight[etype](edges.src['h'])}, etype=etype)
# 将消息聚合到目标节点
g.update_all(dgl.function.copy_e('m', 'm'), dgl.function.sum('m', 'h_sum'), etype=etype)
aggregated += g.nodes[ntype].data['h_sum']
# 加上自环信息(可选,但很重要)
if ntype in feat_dict:
self_loop_weight = nn.Linear(in_feats, out_feats).to(aggregated.device)
aggregated += self_loop_weight(feat_dict[ntype])
new_feat_dict[ntype] = F.relu(aggregated) # 应用激活函数
return new_feat_dict
上面这个类是一个最简化的RGCN层实现,用于理解原理。在实际项目中,强烈建议直接使用DGL内置的 dgl.nn.HeteroGraphConv 模块,它封装了各种图卷积层对异构图的支持,并且经过了高度优化,比自己手写循环要快得多,代码也更简洁。
# 使用DGL内置模块构建一个两层的RGCN模型
class RGCNModel(nn.Module):
def __init__(self, in_feats, hidden_feats, out_feats, etypes):
super().__init__()
# 第一层卷积
self.conv1 = dglnn.HeteroGraphConv({
etype: dglnn.GraphConv(in_feats[stype], hidden_feats) for (stype, etype, dtype) in etypes
}, aggregate='sum')
# 第二层卷积
self.conv2 = dglnn.HeteroGraphConv({
etype: dglnn.GraphConv(hidden_feats, out_feats) for (stype, etype, dtype) in etypes
}, aggregate='sum')
def forward(self, g, feat_dict):
# 第一层
h1 = self.conv1(g, feat_dict)
h1 = {k: F.relu(v) for k, v in h1.items()} # 激活函数
# 第二层
h2 = self.conv2(g, h1)
return h2
这个 RGCNModel 就实用多了。dglnn.HeteroGraphConv 会自动帮你处理不同类型边上的消息传递和聚合,你只需要定义每种边类型使用什么样的卷积层(这里用了最简单的GraphConv)以及聚合方式(这里用sum)。这样,一个强大的异构图编码器就搭建好了,它可以输出每个节点的低维向量表示,用于下游的节点分类或链接预测任务。
3.2 GCMC:专为推荐系统设计的评分预测模型
如果你的目标非常明确,就是做评分预测(比如预测用户会给电影打几分),那么 GCMC 是一个更直接、更有效的选择。它专门为用户-物品二分图设计,并且显式地建模了不同的评分等级。
GCMC的核心流程可以概括为三步:
- 编码:通过一层消息传递,得到用户和物品的嵌入表示。这里,不同的评分值被视为不同的边类型。例如,评分1星、2星...5星就是5种不同的
(用户,评分,电影)边。 - 解码:使用得到的用户嵌入和物品嵌入,通过一个双线性变换(公式里的
u_i^T Q_r v_j)来计算用户对物品给出评分r的可能性。 - 预测:对所有可能的评分r计算可能性,通过softmax得到概率分布,概率最大的那个r就是预测的评分。
在DGL中实现GCMC,我们需要分别构建编码器和解码器。
class GCMCEncoder(nn.Module):
"""GCMC编码器,进行一层消息传递"""
def __init__(self, user_in_feats, movie_in_feats, out_feats, rating_types):
super().__init__()
self.rating_types = rating_types
# 为每一种评分(边类型)定义变换矩阵
self.user_weight = nn.ModuleDict({
f'rating_{r}': nn.Linear(movie_in_feats, out_feats) for r in rating_types
})
self.movie_weight = nn.ModuleDict({
f'rating_{r}': nn.Linear(user_in_feats, out_feats) for r in rating_types
})
def forward(self, g, user_feats, movie_feats):
with g.local_scope():
# 将特征存入图
g.nodes['用户'].data['h'] = user_feats
g.nodes['电影'].data['h'] = movie_feats
new_user_feats = []
new_movie_feats = []
# 消息传递:用户 <-(评分)- 电影
for r in self.rating_types:
etype = ('电影', f'rating_{r}', '用户')
if etype in g.etypes:
# 计算消息:电影特征经过对应评分的权重矩阵变换
g.apply_edges(lambda edges: {'m': self.user_weight[f'rating_{r}'](edges.src['h'])}, etype=etype)
# 聚合消息到用户节点(这里用mean聚合,对应论文中的1/c_{ij}归一化)
g.update_all(dgl.function.copy_e('m', 'm'), dgl.function.mean('m', f'u_{r}'), etype=etype)
# 聚合来自不同评分类型的消息
for r in self.rating_types:
if f'u_{r}' in g.nodes['用户'].data:
new_user_feats.append(g.nodes['用户'].data[f'u_{r}'])
user_out = torch.stack(new_user_feats, dim=1).sum(dim=1) if new_user_feats else torch.zeros_like(user_feats)
# 消息传递:电影 <-(评分)- 用户 (同理,方向相反)
for r in self.rating_types:
etype = ('用户', f'rating_{r}', '电影')
if etype in g.etypes:
g.apply_edges(lambda edges: {'m': self.movie_weight[f'rating_{r}'](edges.src['h'])}, etype=etype)
g.update_all(dgl.function.copy_e('m', 'm'), dgl.function.mean('m', f'm_{r}'), etype=etype)
for r in self.rating_types:
if f'm_{r}' in g.nodes['电影'].data:
new_movie_feats.append(g.nodes['电影'].data[f'm_{r}'])
movie_out = torch.stack(new_movie_feats, dim=1).sum(dim=1) if new_movie_feats else torch.zeros_like(movie_feats)
return F.relu(user_out), F.relu(movie_out)
class GCMCDecoder(nn.Module):
"""GCMC解码器,预测评分"""
def __init__(self, in_feats, rating_types):
super().__init__()
self.rating_types = rating_types
# 为每一种评分学习一个双线性变换矩阵Q_r
self.bilinear = nn.ParameterDict({
str(r): nn.Parameter(torch.randn(in_feats, in_feats)) for r in rating_types
})
self.dropout = nn.Dropout(0.5)
def forward(self, user_embed, movie_embed):
user_embed = self.dropout(user_embed)
movie_embed = self.dropout(movie_embed)
scores = []
for r in self.rating_types:
# 计算 u^T Q_r v
score = torch.matmul(user_embed.unsqueeze(1), self.bilinear[str(r)])
score = torch.matmul(score, movie_embed.unsqueeze(2)).squeeze()
scores.append(score)
scores = torch.stack(scores, dim=1) # [batch_size, num_rating_types]
return F.softmax(scores, dim=1) # 输出每个评分等级的概率
将编码器和解码器组合起来,就是一个完整的GCMC模型。在训练时,我们使用交叉熵损失函数,目标是最小化预测评分分布与真实评分(one-hot编码)之间的差距。GCMC的魅力在于,它把评分这个具体的交互信号直接建模到了图结构里,让模型能够精细地区分“喜欢”、“一般”、“不喜欢”等不同强度的交互,这在很多推荐场景下比简单的“点击/未点击”二分信号包含更多信息。
4. 效果评估与实战技巧:让你的模型真正work起来
模型搭好了,代码跑通了,但怎么知道它好不好用?怎么让它变得更好?这部分我结合自己踩过的坑,分享一些实战经验。
4.1 如何评估推荐系统模型?
评估是模型迭代的指南针。对于链接预测任务(比如预测用户会不会点击),最常用的方法是划分训练集、验证集和测试集。注意,这里的划分是在“边”上进行的,而不是节点。
import numpy as np
from sklearn.metrics import roc_auc_score, average_precision_score
# 假设我们有一个用户-电影二分图g,边类型为‘交互’,表示点击
# 1. 边划分
eids = np.arange(g.num_edges(etype='交互'))
np.random.shuffle(eids)
train_size = int(len(eids) * 0.7)
val_size = int(len(eids) * 0.15)
train_eids = eids[:train_size]
val_eids = eids[train_size: train_size + val_size]
test_eids = eids[train_size + val_size:]
train_g = g.edge_subgraph({'交互': train_eids}, relabel_nodes=False)
# 注意:验证和测试时需要构建一个包含所有节点的图,但掩码掉训练边,用于计算负样本
对于评分预测任务(如GCMC),常用 均方根误差(RMSE) 或 平均绝对误差(MAE) 来衡量预测分数与真实分数的差距。对于Top-K推荐(给用户推荐一个物品列表),则常用 精确率@K(Precision@K)、召回率@K(Recall@K) 和 归一化折损累计增益(NDCG@K)。
def evaluate_topk(model, g, user_ids, k=10):
"""评估Top-K推荐效果"""
model.eval()
all_scores = []
all_labels = []
with torch.no_grad():
# 获取所有用户和物品的嵌入
embeddings = model(g) # 假设model返回所有节点的嵌入字典
user_emb = embeddings['用户']
item_emb = embeddings['电影']
for uid in user_ids:
# 计算该用户与所有物品的得分(例如点积)
scores = torch.matmul(user_emb[uid], item_emb.T) # [num_items]
# 获取真实交互过的物品(在测试集中)
true_items = get_user_test_items(uid) # 需要自己实现这个函数
# 排除训练集中已交互的物品
...
# 计算Precision@K, Recall@K
...
return precision, recall, ndcg
一个关键的技巧是负采样。推荐系统数据中,未观察到的交互(用户-物品对)并不一定代表负反馈(不喜欢),也可能是用户根本没看到。因此,在训练和评估时,我们通常需要从未观察到的交互中采样一部分作为负样本。DGL提供了方便的 dgl.sampling 模块来帮助进行负采样。
4.2 提升模型效果的实用技巧
- 特征工程是关键:图神经网络不是魔术,好的输入特征至关重要。除了ID类特征,尽量利用所有可用的边信息。比如在评分边上,不仅把评分当作边类型,还可以把评分值作为一个连续的特征附加在边上,让模型能感知评分的强度差异。
- 处理稀疏性问题:推荐系统的图往往非常稀疏(一个用户只交互过极少物品)。可以尝试:
- 邻居采样:DGL的
dgl.dataloading.NeighborSampler可以高效地对大规模图的邻居进行采样,避免内存爆炸。 - 使用更强大的聚合器:将
sum或mean聚合换成attention机制(如GATConv),让模型能关注更重要的邻居。DGL的HeteroGraphConv可以轻松集成各种卷积层。 - 引入边Dropout:在训练时随机丢弃一部分边,可以作为一种正则化,防止过拟合,并增加模型的鲁棒性。
- 邻居采样:DGL的
- 融合多模态信息:现实中的节点特征可能是多模态的,比如商品有图片、文本描述。可以在输入模型前,先用CNN处理图片,用BERT处理文本,将得到的向量作为节点的初始特征。DGL的异构图完美支持这种复杂的特征输入。
- 利用元路径(Meta-path):对于更复杂的异构图(如包含用户、商品、品牌、类别的图),可以设计元路径来捕获高阶语义关系,例如“用户-购买->商品-属于->类别”。然后使用像 HAN(Heterogeneous Graph Attention Network) 这样的模型,沿着元路径进行注意力聚合。DGL也提供了相关的示例和接口。
4.3 一个完整的训练循环示例
最后,我们把所有部分串起来,看一个简化的训练循环框架。
import torch.optim as optim
from tqdm import tqdm
def train(model, g, feat_dict, train_eids, val_eids, epochs=100):
optimizer = optim.Adam(model.parameters(), lr=0.01)
criterion = nn.CrossEntropyLoss() # 以分类任务为例
for epoch in range(epochs):
model.train()
# 前向传播
logits = model(g, feat_dict) # 假设模型输出预测logits
# 计算训练损失(仅对训练边)
train_logits = logits[train_eids]
train_labels = g.edges['交互'].data['label'][train_eids] # 假设边上有标签
loss = criterion(train_logits, train_labels)
optimizer.zero_grad()
loss.backward()
optimizer.step()
# 验证
if epoch % 10 == 0:
model.eval()
with torch.no_grad():
val_logits = logits[val_eids]
val_labels = g.edges['交互'].data['label'][val_eids]
val_loss = criterion(val_logits, val_labels)
# 计算评估指标,如AUC
auc = compute_auc(val_logits, val_labels)
print(f'Epoch {epoch}, Train Loss: {loss.item():.4f}, Val Loss: {val_loss.item():.4f}, Val AUC: {auc:.4f}')
这个过程和训练普通的神经网络很像,核心区别在于前向传播时,我们需要把整个图g和特征字典feat_dict喂给模型。DGL behind the scenes 会高效地处理图上的消息传递。
从我自己的项目经验来看,成功应用异构图神经网络的关键,首先在于如何根据业务逻辑合理地构建图。图的构建方式(哪些作为节点?哪些作为边?边有哪些属性?)直接决定了模型能学到什么。其次,耐心地进行调参和特征实验非常重要。一开始效果不好很正常,多从数据本身和图的结构去思考,调整模型架构和训练策略,往往能带来惊喜。DGL社区非常活跃,遇到问题多查文档和GitHub issue,大部分坑都已经有人踩过了。
更多推荐
所有评论(0)