Coverage for hopwise/model/general_recommender/gcmc.py: 83%
210 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-09-30 13:25 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-09-30 13:25 +0000
1# @Time : 2020/9/1 14:00
2# @Author : Changxin Tian
3# @Email : cx.tian@outlook.com
5# UPDATE
6# @Time : 2020/10/1
7# @Author : Changxin Tian
8# @Email : cx.tian@outlook.com
10r"""GCMC
11################################################
13Reference:
14 van den Berg et al. "Graph Convolutional Matrix Completion." in SIGKDD 2018.
16Reference code:
17 https://github.com/riannevdberg/gc-mc
18"""
20import math
22import numpy as np
23import torch
24from torch import nn
26from hopwise.model.abstract_recommender import GeneralRecommender
27from hopwise.model.layers import SparseDropout
28from hopwise.utils import InputType
31class GCMC(GeneralRecommender):
32 r"""GCMC is a model that incorporate graph autoencoders for recommendation.
34 Graph autoencoders are comprised of:
36 1) a graph encoder model :math:`Z = f(X; A)`, which take as input an :math:`N \times D` feature matrix X and
37 a graph adjacency matrix A, and produce an :math:`N \times E` node embedding matrix
38 :math:`Z = [z_1^T,..., z_N^T ]^T`;
40 2) a pairwise decoder model :math:`\hat A = g(Z)`, which takes pairs of node embeddings :math:`(z_i, z_j)` and
41 predicts respective entries :math:`\hat A_{ij}` in the adjacency matrix.
43 Note that :math:`N` denotes the number of nodes, :math:`D` the number of input features,
44 and :math:`E` the embedding size.
46 We implement the model following the original author with a pairwise training mode.
47 """
49 input_type = InputType.PAIRWISE
51 def __init__(self, config, dataset):
52 super().__init__(config, dataset)
54 # load dataset info
55 self.num_all = self.n_users + self.n_items
57 # load parameters info
58 self.dropout_prob = config["dropout_prob"]
59 self.sparse_feature = config["sparse_feature"]
60 self.gcn_output_dim = config["gcn_output_dim"]
61 self.dense_output_dim = config["embedding_size"]
62 self.n_class = config["class_num"]
63 self.num_basis_functions = config["num_basis_functions"]
65 # generate node feature
66 if self.sparse_feature:
67 features = dataset.eye_matrix(form="torch.sparse")
68 i = features._indices()
69 v = features._values()
70 self.user_features = torch.sparse.FloatTensor(
71 i[:, : self.n_users],
72 v[: self.n_users],
73 torch.Size([self.n_users, self.num_all]),
74 ).to(self.device)
75 item_i = i[:, self.n_users :]
76 item_i[0, :] = item_i[0, :] - self.n_users
77 self.item_features = torch.sparse.FloatTensor(
78 item_i, v[self.n_users :], torch.Size([self.n_items, self.num_all])
79 ).to(self.device)
80 else:
81 features = torch.eye(self.num_all).to(self.device)
82 self.user_features, self.item_features = torch.split(features, [self.n_users, self.n_items])
83 self.input_dim = self.user_features.shape[1]
85 # adj matrices for each relation are stored in self.support
86 self.Graph = dataset.norm_adjacency_matrix(form="torch.sparse").to(self.device)
87 self.support = [self.Graph]
89 # accumulation operation
90 self.accum = config["accum"]
91 if self.accum == "stack":
92 div = self.gcn_output_dim // len(self.support)
93 if self.gcn_output_dim % len(self.support) != 0:
94 self.logger.warning(
95 "HIDDEN[0] (=%d) of stack layer is adjusted to %d (in %d splits)."
96 % (self.gcn_output_dim, len(self.support) * div, len(self.support))
97 )
98 self.gcn_output_dim = len(self.support) * div
100 # define layers and loss
101 self.GcEncoder = GcEncoder(
102 accum=self.accum,
103 num_user=self.n_users,
104 num_item=self.n_items,
105 support=self.support,
106 input_dim=self.input_dim,
107 gcn_output_dim=self.gcn_output_dim,
108 dense_output_dim=self.dense_output_dim,
109 drop_prob=self.dropout_prob,
110 device=self.device,
111 sparse_feature=self.sparse_feature,
112 ).to(self.device)
113 self.BiDecoder = BiDecoder(
114 input_dim=self.dense_output_dim,
115 output_dim=self.n_class,
116 drop_prob=0.0,
117 device=self.device,
118 num_weights=self.num_basis_functions,
119 ).to(self.device)
120 self.loss_function = nn.CrossEntropyLoss()
122 def forward(self, user_X, item_X, user, item):
123 # Graph autoencoders are comprised of a graph encoder model and a pairwise decoder model.
124 user_embedding, item_embedding = self.GcEncoder(user_X, item_X)
125 predict_score = self.BiDecoder(user_embedding, item_embedding, user, item)
126 return predict_score
128 def calculate_loss(self, interaction):
129 user = interaction[self.USER_ID]
130 pos_item = interaction[self.ITEM_ID]
131 neg_item = interaction[self.NEG_ITEM_ID]
133 users = torch.cat((user, user))
134 items = torch.cat((pos_item, neg_item))
136 user_X, item_X = self.user_features, self.item_features
137 predict = self.forward(user_X, item_X, users, items)
138 target = torch.zeros(len(user) * 2, dtype=torch.long).to(self.device)
139 target[: len(user)] = 1
141 loss = self.loss_function(predict, target)
142 return loss
144 def predict(self, interaction):
145 user = interaction[self.USER_ID]
146 item = interaction[self.ITEM_ID]
148 user_X, item_X = self.user_features, self.item_features
149 predict = self.forward(user_X, item_X, user, item)
151 score = predict[:, 1]
152 return score
154 def full_sort_predict(self, interaction):
155 user = interaction[self.USER_ID]
157 user_X, item_X = self.user_features, self.item_features
158 predict = self.forward(user_X, item_X, user, None)
160 score = predict[:, 1]
161 return score
164class GcEncoder(nn.Module):
165 r"""Graph Convolutional Encoder
166 GcEncoder take as input an :math:`N \times D` feature matrix :math:`X` and a graph adjacency matrix :math:`A`,
167 and produce an :math:`N \times E` node embedding matrix;
168 Note that :math:`N` denotes the number of nodes, :math:`D` the number of input features,
169 and :math:`E` the embedding size.
170 """
172 def __init__(
173 self,
174 accum,
175 num_user,
176 num_item,
177 support,
178 input_dim,
179 gcn_output_dim,
180 dense_output_dim,
181 drop_prob,
182 device,
183 sparse_feature=True,
184 act_dense=lambda x: x,
185 share_user_item_weights=True,
186 bias=False,
187 ):
188 super().__init__()
189 self.num_users = num_user
190 self.num_items = num_item
191 self.input_dim = input_dim
192 self.gcn_output_dim = gcn_output_dim
193 self.dense_output_dim = dense_output_dim
194 self.accum = accum
195 self.sparse_feature = sparse_feature
197 self.device = device
198 self.dropout_prob = drop_prob
199 self.dropout = nn.Dropout(p=self.dropout_prob)
200 if self.sparse_feature:
201 self.sparse_dropout = SparseDropout(p=self.dropout_prob)
202 else:
203 self.sparse_dropout = nn.Dropout(p=self.dropout_prob)
205 self.dense_activate = act_dense
206 self.activate = nn.ReLU()
207 self.share_weights = share_user_item_weights
208 self.bias = bias
210 self.support = support
211 self.num_support = len(support)
213 # gcn layer
214 if self.accum == "sum":
215 self.weights_u = nn.ParameterList(
216 [
217 nn.Parameter(
218 torch.FloatTensor(self.input_dim, self.gcn_output_dim).to(self.device),
219 requires_grad=True,
220 )
221 for _ in range(self.num_support)
222 ]
223 )
224 if share_user_item_weights:
225 self.weights_v = self.weights_u
226 else:
227 self.weights_v = nn.ParameterList(
228 [
229 nn.Parameter(
230 torch.FloatTensor(self.input_dim, self.gcn_output_dim).to(self.device),
231 requires_grad=True,
232 )
233 for _ in range(self.num_support)
234 ]
235 )
236 else:
237 assert self.gcn_output_dim % self.num_support == 0, (
238 "output_dim must be multiple of num_support for stackGC"
239 )
240 self.sub_hidden_dim = self.gcn_output_dim // self.num_support
242 self.weights_u = nn.ParameterList(
243 [
244 nn.Parameter(
245 torch.FloatTensor(self.input_dim, self.sub_hidden_dim).to(self.device),
246 requires_grad=True,
247 )
248 for _ in range(self.num_support)
249 ]
250 )
251 if share_user_item_weights:
252 self.weights_v = self.weights_u
253 else:
254 self.weights_v = nn.ParameterList(
255 [
256 nn.Parameter(
257 torch.FloatTensor(self.input_dim, self.sub_hidden_dim).to(self.device),
258 requires_grad=True,
259 )
260 for _ in range(self.num_support)
261 ]
262 )
264 # dense layer
265 self.dense_layer_u = nn.Linear(self.gcn_output_dim, self.dense_output_dim, bias=self.bias)
266 if share_user_item_weights:
267 self.dense_layer_v = self.dense_layer_u
268 else:
269 self.dense_layer_v = nn.Linear(self.gcn_output_dim, self.dense_output_dim, bias=self.bias)
271 self._init_weights()
273 def _init_weights(self):
274 init_range = math.sqrt((self.num_support + 1) / (self.input_dim + self.gcn_output_dim))
275 for w in range(self.num_support):
276 self.weights_u[w].data.uniform_(-init_range, init_range)
277 if not self.share_weights:
278 for w in range(self.num_support):
279 self.weights_v[w].data.uniform_(-init_range, init_range)
281 dense_init_range = math.sqrt((self.num_support + 1) / (self.dense_output_dim + self.gcn_output_dim))
282 self.dense_layer_u.weight.data.uniform_(-dense_init_range, dense_init_range)
283 if not self.share_weights:
284 self.dense_layer_v.weight.data.uniform_(-dense_init_range, dense_init_range)
286 if self.bias:
287 self.dense_layer_u.bias.data.fill_(0)
288 if not self.share_weights:
289 self.dense_layer_v.bias.data.fill_(0)
291 def forward(self, user_X, item_X):
292 # ----------------------------------------GCN layer----------------------------------------
294 user_X = self.sparse_dropout(user_X)
295 item_X = self.sparse_dropout(item_X)
297 embeddings = []
298 if self.accum == "sum":
299 wu = 0.0
300 wv = 0.0
301 for i in range(self.num_support):
302 # weight sharing
303 wu = self.weights_u[i] + wu
304 wv = self.weights_v[i] + wv
306 # multiply feature matrices with weights
307 if self.sparse_feature:
308 temp_u = torch.sparse.mm(user_X, wu)
309 temp_v = torch.sparse.mm(item_X, wv)
310 else:
311 temp_u = torch.mm(user_X, wu)
312 temp_v = torch.mm(item_X, wv)
313 all_embedding = torch.cat([temp_u, temp_v])
315 # then multiply with adj matrices
316 graph_A = self.support[i]
317 all_emb = torch.sparse.mm(graph_A, all_embedding)
318 embeddings.append(all_emb)
320 embeddings = torch.stack(embeddings, dim=1)
321 embeddings = torch.sum(embeddings, dim=1)
322 else:
323 for i in range(self.num_support):
324 # multiply feature matrices with weights
325 if self.sparse_feature:
326 temp_u = torch.sparse.mm(user_X, self.weights_u[i])
327 temp_v = torch.sparse.mm(item_X, self.weights_v[i])
328 else:
329 temp_u = torch.mm(user_X, self.weights_u[i])
330 temp_v = torch.mm(item_X, self.weights_v[i])
331 all_embedding = torch.cat([temp_u, temp_v])
333 # then multiply with adj matrices
334 graph_A = self.support[i]
335 all_emb = torch.sparse.mm(graph_A, all_embedding)
336 embeddings.append(all_emb)
338 embeddings = torch.cat(embeddings, dim=1)
340 users, items = torch.split(embeddings, [self.num_users, self.num_items])
342 u_hidden = self.activate(users)
343 v_hidden = self.activate(items)
345 # ----------------------------------------Dense Layer----------------------------------------
347 u_hidden = self.dropout(u_hidden)
348 v_hidden = self.dropout(v_hidden)
350 u_hidden = self.dense_layer_u(u_hidden)
351 v_hidden = self.dense_layer_v(v_hidden)
353 u_outputs = self.dense_activate(u_hidden)
354 v_outputs = self.dense_activate(v_hidden)
356 return u_outputs, v_outputs
359class BiDecoder(nn.Module):
360 """Bi-linear decoder
361 BiDecoder takes pairs of node embeddings and predicts respective entries in the adjacency matrix.
362 """
364 def __init__(self, input_dim, output_dim, drop_prob, device, num_weights=3, act=lambda x: x):
365 super().__init__()
366 self.input_dim = input_dim
367 self.output_dim = output_dim
368 self.num_weights = num_weights
369 self.device = device
371 self.activate = act
372 self.dropout_prob = drop_prob
373 self.dropout = nn.Dropout(p=self.dropout_prob)
375 self.weights = nn.ParameterList(
376 [
377 nn.Parameter(orthogonal([self.input_dim, self.input_dim]).to(self.device))
378 for _ in range(self.num_weights)
379 ]
380 )
381 self.dense_layer = nn.Linear(self.num_weights, self.output_dim, bias=False)
382 self._init_weights()
384 def _init_weights(self):
385 dense_init_range = math.sqrt(self.output_dim / (self.num_weights + self.output_dim))
386 self.dense_layer.weight.data.uniform_(-dense_init_range, dense_init_range)
388 def forward(self, u_inputs, i_inputs, users, items=None):
389 u_inputs = self.dropout(u_inputs)
390 i_inputs = self.dropout(i_inputs)
392 if items is not None:
393 users_emb = u_inputs[users]
394 items_emb = i_inputs[items]
396 basis_outputs = []
397 for i in range(self.num_weights):
398 users_emb_temp = torch.mm(users_emb, self.weights[i])
399 scores = torch.mul(users_emb_temp, items_emb)
400 scores = torch.sum(scores, dim=1)
401 basis_outputs.append(scores)
402 else:
403 users_emb = u_inputs[users]
404 items_emb = i_inputs
406 basis_outputs = []
407 for i in range(self.num_weights):
408 users_emb_temp = torch.mm(users_emb, self.weights[i])
409 scores = torch.mm(users_emb_temp, items_emb.transpose(0, 1))
410 basis_outputs.append(scores.view(-1))
412 basis_outputs = torch.stack(basis_outputs, dim=1)
413 basis_outputs = self.dense_layer(basis_outputs)
414 output = self.activate(basis_outputs)
416 return output
419def orthogonal(shape, scale=1.1):
420 """Initialization function for weights in class GCMC.
421 From Lasagne. Reference: Saxe et al., http://arxiv.org/abs/1312.6120
422 """
423 flat_shape = (shape[0], np.prod(shape[1:]))
424 a = np.random.normal(0.0, 1.0, flat_shape)
425 u, _, v = np.linalg.svd(a, full_matrices=False)
427 # pick the one with the correct shape
428 q = u if u.shape == flat_shape else v
429 q = q.reshape(shape)
430 return torch.tensor(scale * q[: shape[0], : shape[1]], dtype=torch.float32)