图神经网络(五)基于GNN的图表示学习(3)基于图自编码器的推荐系统
5.3 基于图自编码器的推荐系统
下面讲解一个基于图自编码器实现简单的推荐任务 [14] 的例子。推荐系统要建立的是用户与商品之间的关系,这里我们以简化后的用户对商品的评分为例进行介绍,如图5-5。假设用户与商品之间的交互行为只存在评分,分值从 1 1 1 分到 5 5 5 分。如果用户 u u u 对商品 v v v 进行评分,评分为 r r r ,就是说用户 u u u 与商品 v v v 之间存在一条边,边的类型为 r r r ,其中 r ∈ R r∈R r∈R 。基于这种交互关系对用户进行商品推荐实际上就是要预测哪些商品与用户之间可能存在边,这样的问题称为边预测问题。对于这种边预测问题,我们将其看作矩阵补全问题,用户与商品之间的交互行为构成了一个二部图,可以通过用户与商品的邻接矩阵表示为 A A A ,矩阵中的值就是评分,推荐就是要对矩阵没有评分的位置进行预测 [0] 。
图5-5 基于图自编码器的推荐 [1]
图5-5为基于图自编码器的推荐系统的架构图。如图所示,首先将用户-商品矩阵转化成用户-商品二部图,然后使用图自编码器对二部图进行建模,这里使用 GCN \text{GCN} GCN 作为编码器,然后通过解码器对邻接矩阵中的边信息进行重构,由此完成边预测的任务。对于邻接矩阵 A A A 按照不同的评分 r r r 进行分解,可以得到每个评 r r r 分对应的一个邻接矩阵 A r A_r Ar ,它在评分为 V V V 的位置上值为 1 1 1 ,否则为 0 0 0 。请注意,在这里将不同的评分当作不同的关系进行处理,而不是当做边上的属性进行预测。接下来,使用 R-GCN \text{R-GCN} R-GCN 双重聚合的思路对节点的表示进行学习:先对统一关系的邻居进行聚合,在对所有的邻居进行聚合。用式子表示如下式所示:
h i = σ ( Agg { ∑ v j ∈ N v i ( k ) 1 c i , r W r h j , ∀ r ∈ R } ) \boldsymbol{h}_i=\sigma\Bigg(\text{Agg}\bigg\{∑_{v_j∈N_{v_i}^{(k)}}\frac{1}{c_{i,r}}W_r\boldsymbol{h}_j,∀r∈R\bigg\}\Bigg) hi=σ(Agg{
vj∈Nvi(k)∑ci,r1Wrhj,∀r∈R}) 其中 W r W_r Wr 是每类评分对应的权重参数, c i , j c_{i,j} ci,j 是一个归一化参数,可以选择 ∣ N i ∣ |N_i| ∣Ni∣ 或者 ∣ N i ∣ ∣ N j ∣ \sqrt{|N_i ||N_j |} ∣Ni∣∣Nj∣ 。第二重聚合时的函数为 Agg \text{Agg} Agg ,可以选择拼接、求和或者更复杂的聚合函数,代码清单5-1是基于拼接的编码器实现,代码清单5-2是基于求和的编码器实现。
代码清单5-1 基于拼接的编码器
class StackGCNEncoder(nn.Module):
def __init__(self, input_dim, output_dim, num_support,
dropout=0.,
use_bias=False, activation=F.relu):
"""对得到的每类评分使用级联的方式进行聚合
Args:
----
input_dim (int): 输入的特征维度
output_dim (int): 输出的特征维度,需要output_dim % num_support = 0
num_support (int): 评分的类别数,比如1~5分,值为5
use_bias (bool, optional): 是否使用偏置. Defaults to False.
activation (optional): 激活函数. Defaults to F.relu.
"""
super(StackGCNEncoder, self).__init__()
self.input_dim = input_dim
self.output_dim = output_dim
self.num_support = num_support
self.dropout = dropout
self.use_bias = use_bias
self.activation = activation
assert output_dim % num_support == 0
self.weight = nn.Parameter(torch.Tensor(input_dim, output_dim))
if self.use_bias:
self.bias_user = nn.Parameter(torch.Tensor(output_dim, ))
self.bias_item = nn.Parameter(torch.Tensor(output_dim, ))
self.dropout = InputDropout(1 - dropout)
self.reset_parameters()
def reset_parameters(self):
init.xavier_uniform_(self.weight)
if self.use_bias:
init.zeros_(self.bias_user)
init.zeros_(self.bias_item)
def forward(self, user_supports, item_supports, user_inputs, item_inputs):
"""StackGCNEncoder计算逻辑
Args:
user_supports (list of torch.sparse.FloatTensor):
归一化后每个评分等级对应的用户与商品邻接矩阵
item_supports (list of torch.sparse.FloatTensor):
归一化后每个评分等级对应的商品与用户邻接矩阵
user_inputs (torch.Tensor): 用户特征的输入
item_inputs (torch.Tensor): 商品特征的输入
Returns:
[torch.Tensor]: 用户的隐层特征
[torch.Tensor]: 商品的隐层特征
"""
assert len(user_supports) == len(item_supports) == self.num_support
user_inputs = self.dropout(user_inputs)
item_inputs = self.dropout(item_inputs)
user_hidden = []
item_hidden = []
weights = torch.split(self.weight, self.output_dim//self.num_support, dim=1)
for i in range(self.num_support):
tmp_u = torch.matmul(user_inputs, weights[i])
tmp_v = torch.matmul(item_inputs, weights[i])
tmp_user_hidden = torch.sparse.mm(user_supports[i], tmp_v)
tmp_item_hidden = torch.sparse.mm(item_supports[i], tmp_u)
user_hidden.append(tmp_user_hidden)
item_hidden.append(tmp_item_hidden)
user_hidden = torch.cat(user_hidden, dim=1)
item_hidden = torch.cat(item_hidden, dim=1)
user_outputs = self.activation(user_hidden)
item_outputs = self.activation(item_hidden)
if self.use_bias:
user_outputs += self.bias_user
item_outputs += self.bias_item
return user_outputs, item_outputs
代码清单5-2 基于求和的编码器
class SumGCNEncoder(nn.Module):
def __init__(self, input_dim, output_dim, num_support,
dropout=0.,
use_bias=False, activation=F.relu):
"""对得到的每类评分使用求和的方式进行聚合
Args:
input_dim (int): 输入的特征维度
output_dim (int): 输出的特征维度,需要output_dim % num_support = 0
num_support (int): 评分的类别数,比如1~5分,值为5
use_bias (bool, optional): 是否使用偏置. Defaults to False.
activation (optional): 激活函数. Defaults to F.relu.
"""
super(SumGCNEncoder, self).__init__()
self.input_dim = input_dim
self.output_dim = output_dim
self.num_support = num_support
self.use_bias = use_bias
self.activation = activation
self.weight = nn.Parameter(torch.Tensor(
input_dim, output_dim * num_support))
if self.use_bias:
self.bias_user = nn.Parameter(torch.Tensor(output_dim, ))
self.bias_item = nn.Parameter(torch.Tensor(output_dim, ))
self.dropout = InputDropout(1 - dropout)
self.reset_parameters()
def reset_parameters(self):
init.xavier_uniform_(self.weight)
if self.use_bias:
init.zeros_(self.bias_user)
init.zeros_(self.bias_item)
def forward(self, user_supports, item_supports, user_inputs, item_inputs):
"""SumGCNEncoder计算逻辑
Args:
user_supports (list of torch.sparse.FloatTensor):
归一化后每个评分等级对应的用户与商品邻接矩阵
item_supports (list of torch.sparse.FloatTensor):
归一化后每个评分等级对应的商品与用户邻接矩阵
user_inputs (torch.Tensor): 用户特征的输入
item_inputs (torch.Tensor): 商品特征的输入
Returns:
[torch.Tensor]: 用户的隐层特征
[torch.Tensor]: 商品的隐层特征
"""
assert len(user_supports) == len(item_supports) == self.num_support
user_inputs = self.dropout(user_inputs)
item_inputs = self.dropout(item_inputs)
user_hidden = []
item_hidden = []
weights = torch.split(self.weight, self.output_dim, dim=1)
for i in range(self.num_support):
w = sum(weights[:(i + 1)])
tmp_u = torch.matmul(user_inputs, w)
tmp_v = torch.matmul(item_inputs, w)
tmp_user_hidden = torch.sparse.mm(user_supports[i], tmp_v)
tmp_item_hidden = torch.sparse.mm(item_supports[i], tmp_u)
user_hidden.append(tmp_user_hidden)
item_hidden.append(tmp_item_hidden)
user_hidden, item_hidden = sum(user_hidden), sum(item_hidden)
user_outputs = self.activation(user_hidden)
item_outputs = self.activation(item_hidden)
if self.use_bias:
user_outputs += self.bias_user
item_outputs += self.bias_item
return user_outputs, item_outputs
上面得到的 GCN \text{GCN} GCN 编码特征需要再经过一个非线性变换以得到最终的特征,如下式所示:
u i = σ ( W h i + b ) \boldsymbol{u}_i=σ(W\boldsymbol{h}_i+\boldsymbol{b}) ui=σ(Whi+b)用户与商品可以共享相同的参数 W W W ,也可以使用不同的变换参数,如代码清单5-3所示。
代码清单5-3 非线性变换
class FullyConnected(nn.Module):
def __init__(self, input_dim, output_dim, dropout=0.,
use_bias=False, activation=F.relu,
share_weights=False):
"""非线性变换层
Args:
----
input_dim (int): 输入的特征维度
output_dim (int): 输出的特征维度,需要output_dim % num_support = 0
use_bias (bool, optional): 是否使用偏置. Defaults to False.
activation (optional): 激活函数. Defaults to F.relu.
share_weights (bool, optional): 用户和商品是否共享变换权值. Defaults to False.
"""
super(FullyConnected, self).__init__()
self.input_dim = input_dim
self.output_dim = output_dim
self.use_bias = use_bias
self.activation = activation
self.share_weights = share_weights
if not share_weights:
self.weights_u = nn.Parameter(torch.Tensor(input_dim, output_dim))
self.weights_v = nn.Parameter(torch.Tensor(input_dim, output_dim))
if use_bias:
self.user_bias = nn.Parameter(torch.Tensor(output_dim))
self.item_bias = nn.Parameter(torch.Tensor(output_dim))
else:
self.weights_u = nn.Parameter(torch.Tensor(input_dim, output_dim))
self.weights_v = self.weights_u
if use_bias:
self.user_bias = nn.Parameter(torch.Tensor(output_dim))
self.item_bias = self.user_bias
self.dropout = nn.Dropout(dropout)
self.reset_parameters()
def reset_parameters(self):
if not self.share_weights:
init.xavier_uniform_(self.weights_u)
init.xavier_uniform_(self.weights_v)
if self.use_bias:
init.normal_(self.user_bias, std=0.5)
init.normal_(self.item_bias, std=0.5)
else:
init.xavier_uniform_(self.weights_u)
if self.use_bias:
init.normal_(self.user_bias, std=0.5)
def forward(self, user_inputs, item_inputs):
"""前向传播
Args:
user_inputs (torch.Tensor): 输入的用户特征
item_inputs (torch.Tensor): 输入的商品特征
Returns:
[torch.Tensor]: 输出的用户特征
[torch.Tensor]: 输出的商品特征
"""
x_u = self.dropout(user_inputs)
x_u = torch.matmul(x_u, self.weights_u)
x_v = self.dropout(item_inputs)
x_v = torch.matmul(x_v, self.weights_v)
u_outputs = self.activation(x_u)
v_outputs = self.activation(x_v)
if self.use_bias:
u_outputs += self.user_bias
v_outputs += self.item_bias
return u_outputs, v_outputs
这样就得到了用户和商品的表达,下面我们根据用户与商品的表达,通过解码器重构邻接矩阵,由于有多种评分等级,需要对每种评分等级进行重构,这里将这个重构转换为一个分类问题来实现,具体来说就是,根据用户和商品的特征得到条件概率
p ( A ^ i j = r ∣ u i , v j ) p(\hat{A}_{ij}=r\mid \boldsymbol{u}_i,\boldsymbol{v}_j) p(A^ij=r∣ui,vj)这个值通过 softmax \text{softmax} softmax 归一化得到,如下式所示,其中 Q r ∈ R d × d Q_r∈R^{d×d} Qr∈Rd×d ,其中 d d d 为编码特征维度:
p ( A ^ i j = r ) = e u i T Q r v j ∑ s ∈ R e u i T Q s v j p(\hat{A}_{ij}=r)=\frac{e^{u_i^\text{T} Q_r v_j}}{\sum\limits_{s∈R}e^{u_i^\text{T} Q_s v_j}} p(A^ij=r)=s∈R∑euiTQsvjeuiTQrvj
损失函数选择交叉熵,每类评分的损失通过求和得到,如下式所示:
L = − ∑ i , j ; Ω i j = 1 ∑ r = 1 R I [ r = A i j ] log p ( A ^ i j = r ) \mathcal{L}=-\sum\limits_{i,j; \Omega_{ij}=1}\sum\limits_{r=1}^RI[r=A_{ij}]\logp(\hat{A}_{ij}=r) L=−i,j;Ωij=1∑r=1∑RI[r=Aij]logp(A^ij=r)
下面根据用户和商品的特征得到条件概率 p ( A ^ i j = r ) p(\hat{A}_{ij}=r) p(A^ij=r) 的公式,来实现解码器,为了便于实现,将用户与商品之间的邻接矩阵转换为 ( id ( u i ) , id ( v j ) , r ) \big(\text{id}(\boldsymbol{u}_i ),\text{id}(\boldsymbol{v}_j ),r\big) (id(ui),id(vj),r) 的 RDF \text{RDF} RDF 形式,其中user_indices
就是所有 RDF \text{RDF} RDF 的起点,如代码清单5-4所示:
代码清单5-4 解码器
class Decoder(nn.Module):
def __init__(self, input_dim, num_weights, num_classes, dropout=0., activation=F.relu):
"""解码器
Args:
----
input_dim (int): 输入的特征维度
num_weights (int): basis weight number
num_classes (int): 总共的评分级别数,eg. 5
"""
super(Decoder, self).__init__()
self.input_dim = input_dim
self.num_weights = num_weights
self.num_classes = num_classes
self.activation = activation
self.weight = nn.ParameterList([nn.Parameter(torch.Tensor(input_dim, input_dim))
for _ in range(num_weights)])
self.weight_classifier = nn.Parameter(torch.Tensor(num_weights, num_classes))
self.dropout = nn.Dropout(dropout)
self.reset_parameters()
def reset_parameters(self):
for i in range(len(self.weight)):
init.orthogonal_(self.weight[i], gain=1.1)
init.xavier_uniform_(self.weight_classifier)
def forward(self, user_inputs, item_inputs, user_indices, item_indices):
"""计算非归一化的分类输出
Args:
user_inputs (torch.Tensor): 用户的隐层特征
item_inputs (torch.Tensor): 商品的隐层特征
user_indices (torch.LongTensor):
所有交互行为中用户的id索引,与对应的item_indices构成一条边,shape=(num_edges, )
item_indices (torch.LongTensor):
所有交互行为中商品的id索引,与对应的user_indices构成一条边,shape=(num_edges, )
Returns:
[torch.Tensor]: 未归一化的分类输出,shape=(num_edges, num_classes)
"""
user_inputs = self.dropout(user_inputs)
item_inputs = self.dropout(item_inputs)
user_inputs = user_inputs[user_indices]
item_inputs = item_inputs[item_indices]
basis_outputs = []
for i in range(self.num_weights):
tmp = torch.matmul(user_inputs, self.weight[i])
out = torch.sum(tmp * item_inputs, dim=1, keepdim=True)
basis_outputs.append(out)
basis_outputs = torch.cat(basis_outputs, dim=1)
outputs = torch.matmul(basis_outputs, self.weight_classifier)
outputs = self.activation(outputs)
return outputs
参考文献
[0] 刘忠雨, 李彦霖, 周洋.《深入浅出图神经网络: GNN原理解析》.机械工业出版社.
[1] Berg R , Kipf T N , Welling M.Graph convolutional matrix completion[J].arXiv preprint arXiv:1706.02263,2017.
[14] Berg R,Kipf T N,Welling M.Graph convolutional matrix completion[J].arXiv preprint arXiv:1706.02263,2017.