在之前的两篇文章中,我们介绍了数据处理及图的定义,采样,这篇文章是该系列的最后一篇文章——介绍数据加载及PinSAGE模型的定义与训练。
数据加载
这块涉及到的文件主要有model.py和sampler.py。
熟悉Pytorch搭建模型的同学应该知道,如果要自己定义数据输入模型的格式则需要自定义Dataloader创建时的collate_fn函数,此处的collator_fn函数定义在PinSAGECollator中:
class PinSAGECollator(object):
def __init__(self, sampler, g, ntype, textset):
self.sampler = sampler
self.ntype = ntype
self.g = g
self.textset = textset
def collate_train(self, batches):
heads, tails, neg_tails = batches[0]
# Construct multilayer neighborhood via PinSAGE...
pos_graph, neg_graph, blocks = self.sampler.sample_from_item_pairs(
heads, tails, neg_tails
)
assign_features_to_blocks(blocks, self.g, self.textset, self.ntype)
return pos_graph, neg_graph, blocks
def collate_test(self, samples):
batch = torch.LongTensor(samples)
blocks = self.sampler.sample_blocks(batch)
assign_features_to_blocks(blocks, self.g, self.textset, self.ntype)
return blocks
可以看到PinSAGECollator定义了两个将来用于构建Dataloader的函数collate_train和collate_test。collate_train每调用一次将会返回一个batch的pos_graph和neg_graph、blocks用于模型训练。
dataloader的构建如下:
collator = sampler_module.PinSAGECollator(
neighbor_sampler, g, item_ntype, textset
)
dataloader = DataLoader(
batch_sampler,
collate_fn=collator.collate_train,
num_workers=args.num_workers,
)
通过以下过程获取dataloader一个batch内的数据并送入模型:
dataloader_it = iter(dataloader)
for epoch_id in range(args.num_epochs):
model.train()
for batch_id in tqdm.trange(args.batches_per_epoch):
pos_graph, neg_graph, blocks = next(dataloader_it)
# Copy to GPU
for i in range(len(blocks)):
blocks[i] = blocks[i].to(device)
pos_graph = pos_graph.to(device)
neg_graph = neg_graph.to(device)
# 将正样本图、负样本图以及blocks送入模型
loss_tmp = model(pos_graph, neg_graph, blocks)
loss = loss_tmp.mean()
opt.zero_grad()
loss.backward()
opt.step()
模型定义
PinSAGE模型的构建主要涉及到两个文件model.py和layers.py两个文件。首先看PinSAGE的主要结构:
class PinSAGEModel(nn.Module):
def __init__(self, full_graph, ntype, textsets, hidden_dims, n_layers):
super().__init__()
self.proj = layers.LinearProjector(
full_graph, ntype, textsets, hidden_dims
)
self.sage = layers.SAGENet(hidden_dims, n_layers)
self.scorer = layers.ItemToItemScorer(full_graph, ntype)
def forward(self, pos_graph, neg_graph, blocks):
h_item = self.get_repr(blocks)
pos_score = self.scorer(pos_graph, h_item)
neg_score = self.scorer(neg_graph, h_item)
# pos_score和neg_score对应的是一个节点和正样本对之间的相似性和该节点与负样本之间的相似性
# 优化的目标是neg_score - pos_score的值要小于-1
return (neg_score - pos_score + 1).clamp(min=0)
def get_repr(self, blocks):
# 卷积层数决定了采样层数,决定了有多少个block,block[0]中的源节点是初始节点,block[-1]中的目标节点是最终的输出节点
# 每个block中的源节点都会包含该block的输出节点
# 对所有节点的数据先做embedding
h_item = self.proj(blocks[0].srcdata)
h_item_dst = self.proj(blocks[-1].dstdata)
# 卷积后的值加上之前的值,类似于跳跃连接
tmp = h_item_dst + self.sage(blocks, h_item)
return tmp
PinSAGE模型的线性层——self.proj
这个层的主要作用是对movie节点的id,genere,title等属性进行Embedding,将每个节点映射为一个向量,这块没什么好讲的,就不展开介绍了。
PinSAGE模型的节点信息传递、聚合、更新层(卷积层)——self.sage
这个层主要由layers.py中的SAGENet类构成,SAGENet中由调用了同样在layers中的WeightedSAGEConv类,而这个类的核心就是负责信息的传递、更新、聚合,所以我个人将这个层也理解为卷积层。先看一下SAGENet的代码:
class SAGENet(nn.Module):
def __init__(self, hidden_dims, n_layers):
"""
g : DGLGraph
The user-item interaction graph.
This is only for finding the range of categorical variables.
item_textsets : torchtext.data.Dataset
The textual features of each item node.
"""
super().__init__()
self.convs = nn.ModuleList()
for _ in range(n_layers):
self.convs.append(
WeightedSAGEConv(hidden_dims, hidden_dims, hidden_dims)
)
def forward(self, blocks, h):
for layer, block in zip(self.convs, blocks):
# 因为每个block中的目标节点都包含在源节点的最前面
print(block.num_nodes("DST/" + block.ntypes[0]))
h_dst = h[: block.num_nodes("DST/" + block.ntypes[0])]
h = layer(block, (h, h_dst), block.edata["weights"])
return h
可以看到其初始化方法里面定义了一个nn.ModuleList(),这就是一个可以将模型里的各个层或者模块都加入到里面的数组,相当于是一次封装,之后数据直接过这个nn.ModuleList()就可以。nn.ModuleList()里会加入n_layers个 WeightedSAGEConv模块。
PinSAGEModel在执行前向传播时,先调用了其本身的get_repr方法,将blocks中的第一个block中的源节点数据和第二个block中的目标节点数据过一下线性层,获得节点的向量表示,之后执行tmp = h_item_dst + self.sage(blocks, h_item)
这个过程,在这个过程中就进入了SAGENet的前向传播过程。
在SAGENet前向传播时,先获得一个WeightedSAGEConv层和一个block,然后做一次卷积,我们可以看到给卷积层传的参数是第一个block的目标节点(因为block的源节点会包含目标节点,所以可以直接通过源节点获取目标节点,这在之前采样那篇文章里讲block时已经讲过了,不懂的点“在工业界落地的PinSAGE图卷积算法原理及源码学习(二)采样”),之后将这个block以及其中的源节点,目标节点,源节点到目标节点的边的权重传给了WeightedSAGEConv层。
接下来分析WeightedSAGEConv类的主要组成:
class WeightedSAGEConv(nn.Module):
def __init__(self, input_dims, hidden_dims, output_dims, act=F.relu):
super().__init__()
self.act = act
self.Q = nn.Linear(input_dims, hidden_dims)
self.W = nn.Linear(input_dims + hidden_dims, output_dims)
self.reset_parameters()
self.dropout = nn.Dropout(0.5)
def reset_parameters(self):
gain = nn.init.calculate_gain("relu")
nn.init.xavier_uniform_(self.Q.weight, gain=gain)
nn.init.xavier_uniform_(self.W.weight, gain=gain)
nn.init.constant_(self.Q.bias, 0)
nn.init.constant_(self.W.bias, 0)
def forward(self, g, h, weights):
"""
g : graph
h : node features
weights : scalar edge weights
"""
h_src, h_dst = h
with g.local_scope():
g.srcdata["n"] = self.act(self.Q(self.dropout(h_src)))
print(g.srcdata["n"].shape)
g.edata["w"] = weights.float()
print(g.edata["w"].shape)
g.update_all(fn.u_mul_e("n", "w", "m"), fn.sum("m", "n"))
g.update_all(fn.copy_e("w", "m"), fn.sum("m", "ws"))
n = g.dstdata["n"]
ws = g.dstdata["ws"].unsqueeze(1).clamp(min=1)
z = self.act(self.W(self.dropout(torch.cat([n / ws, h_dst], 1))))
# 第一个参数表示求二范数,第二个参数代表求哪个维度的二范数
z_norm = z.norm(2, 1, keepdim=True)
z_norm = torch.where(
# torch.tensor(1.0).to(z_norm) 将1.0这个tensor转成格式和z_norm一样的数据
z_norm == 0, torch.tensor(1.0).to(z_norm), z_norm
)
z = z / z_norm
return z
WeightedSAGEConv的初始化方法中主要定义了一些权重参数矩阵,这些都是将来节点上的信息做传递、聚合、更新时要用到的。
WeightedSAGEConv的前向传播forward主要负责每次卷积的工作,联系上文,SAGENet前向传播时每一次卷积向WeightedSAGEConv中传进来的是当前的block以及其中的源节点,目标节点,源节点到目标节点的边的权重,WeightedSAGEConv的前向传播中主要代码的含义为:
with g.local_scope():
包裹着的内容其中的各种计算都不会改变节点原始信息,这样做只是为了方便计算,不会改变原始图。g.srcdata["n"] = self.act(self.Q(self.dropout(h_src)))
:源节点传进来之后进行了dropout、乘上参数矩阵以及经过激活函数的操作,会将此时得到的信息存入到源节点的‘n’属性中。g.update_all(fn.u_mul_e("n", "w", "m"), fn.sum("m", "n"))
:将源节点信息乘上源节点到目标节点的权重得到的值保存为‘m’,将‘m’与源节点‘n’属性中的值相加聚合到目标节点中并保存到目标节点的‘n’属性中。其中fn.u_mul_e是内置消息功能,如果特征形状相同,则通过在源节点和边(edge)特征之间执行逐元素乘法来计算边缘上的消息;否则,它将首先将特征广播到新形状并执行逐元素操作。因为该例中weights每条边上是单个的值,所以此处会进行广播的操作。g.update_all(fn.copy_e("w", "m"), fn.sum("m", "ws"))
:先将边上的权重值复制保存为‘m’,再将‘m’的值相加聚合到目标节点中保存为‘ws’属性,个人感觉这个步骤举例说明的话类似:目标节点有两条边,这两条边的权重值分别为2,5,则将7这个值保存为该目标节点的‘ws’属性。z = self.act(self.W(self.dropout(torch.cat([n / ws, h_dst], 1))))
:这行代码中的n / ws其实是图卷积的一个经典操作,即降低权重较大的边传递过来的信息对目标节点的影响程度,同时拼接上目标节点原来的信息乘一个参数参数矩阵W
之后再经过一些归一化操作返回最终卷积后目标节点的值,这就是一次卷积的过程,第二次卷积过程和第一次类似。对于DGL中的消息传递函数和消息聚合函数不太了解的同学可以参考这篇文章[1]。
PinSAGE模型的节点相似性计算层——self.scorer
先看这个层的定义self.scorer = layers.ItemToItemScorer(full_graph, ntype)
,再看ItemToItemScorer这个类的定义:
class ItemToItemScorer(nn.Module):
def __init__(self, full_graph, ntype):
super().__init__()
n_nodes = full_graph.num_nodes(ntype)
self.bias = nn.Parameter(torch.zeros(n_nodes, 1))
def _add_bias(self, edges):
bias_src = self.bias[edges.src[dgl.NID]]
bias_dst = self.bias[edges.dst[dgl.NID]]
return {"s": edges.data["s"] + bias_src + bias_dst}
def forward(self, item_item_graph, h):
"""
item_item_graph : graph consists of edges connecting the pairs
h : hidden state of every node
"""
with item_item_graph.local_scope():
# 为正样本图和负样本图上的节点赋予一个新属性,新属性是卷积后的节点的embedding
item_item_graph.ndata["h"] = h
# 正负样本图上有边的节点的embedding相乘(此处应该是向量点积),消息存储为s
item_item_graph.apply_edges(fn.u_dot_v("h", "h", "s"))
item_item_graph.apply_edges(self._add_bias)
pair_score = item_item_graph.edata["s"]
return pair_score
其中主要的就是前向传播的过程,通过之前的卷积已经得到了pos_graph和neg_graph中所有节点的向量表示,这个向量表示是聚合其采样得到的两层邻居节点的信息得到的。将这些向量表示对应的存储到pos_graph和neg_graph中的节点的‘h’属性上:item_item_graph.ndata["h"] = h
。之后将有边相连的节点上的‘h’信息两两相乘并保存到边的‘s’属性上,返回这个属性的值。这个值其实就是正样本对之间的相似性评分和负样本对之间的相似性评分。
PinSAGE模型前向传播过程对这块的调用为:
pos_score = self.scorer(pos_graph, h_item)
neg_score = self.scorer(neg_graph, h_item)
得到正负样本对相似性评分之后,返回模型之后要优化的目标:
return (neg_score - pos_score + 1).clamp(min=0)
.clamp(min=0)
会让neg_score - pos_score + 1
得到的值里所有小于0的值变成0,那模型优化时便会将neg_score - pos_score
的值朝着大于-1的目标优化,这个其实是有点类似max margin的感觉,感兴趣的同学可以自行了解一下这个损失函数的相关知识,说白了就是最小化负样本对相似性评分与正样本对相似性评分的差值,即负样本对相似性越小越好,正样本对相似性越大越好。
之后就是模型训练的常规操作了,model.py中的:
loss = loss_tmp.mean()
opt.zero_grad()
loss.backward()
opt.step()
至此PinSAGE模型原理及源码分析就结束了,在这个系列中我基本上将DGL中实现PinSAGE模型的这个example从头到尾的捋了一遍,整个过程加深了自己对空域图卷积算法的理解,之前一直在看论文了解算法原理什么的,但有时候论文中的公式和一些叙述真的让人看得丈二和尚摸不着头脑。如果不是详细的捋一遍代码,可能看完这个模型我只知道是有这么一回事,其中算法的一些具体细节根本没弄清楚。其实从开始了解这个算法到写完这个系列的文章中间花了很长的时间,其中碰到了很多困难,比如:
- 完全不了解DGL,刚开始甚至连利用DGL创建图都不会,更别提后面DGL中的采样器,block等的原理是什么了。
- 不了解随机游走采样和PinSAGE采样的目的到底是为了什么?
- 采样得到的pos_graph和neg_graph究竟怎样和之后的block联系起来?
- ......
所幸详细学习完这份代码之后,以上的疑惑基本上都得到了解决,在这个过程中付出的时间与精力我觉得是值得的。如果我这个系列的文章也能对此刻正在学习图卷积或者PinSAGE的您能提供一点思路,节省一点时间,那就让我更开心了!
不积跬步无以至千里,不积小流无以成江海。
祝我们都学有所成,Best Wishes!
鉴于本人水平及精力时间有限,文章中难免有错误的地方,如果对您造成疑惑,欢迎在评论区指出或与我私信交流,看到会第一时间回复。或添加我的微信公众号进行交流。
微信公众号:琛锡的算法笔记。