Files
2026-07-13 13:35:51 +08:00

434 lines
14 KiB
Python

"""NN modules"""
import dgl.function as fn
import dgl.nn.pytorch as dglnn
import torch as th
import torch.nn as nn
from torch.nn import init
from utils import get_activation, to_etype_name
class GCMCGraphConv(nn.Module):
"""Graph convolution module used in the GCMC model.
Parameters
----------
in_feats : int
Input feature size.
out_feats : int
Output feature size.
weight : bool, optional
If True, apply a linear layer. Otherwise, aggregating the messages
without a weight matrix or with an shared weight provided by caller.
device: str, optional
Which device to put data in. Useful in mix_cpu_gpu training and
multi-gpu training
"""
def __init__(
self, in_feats, out_feats, weight=True, device=None, dropout_rate=0.0
):
super(GCMCGraphConv, self).__init__()
self._in_feats = in_feats
self._out_feats = out_feats
self.device = device
self.dropout = nn.Dropout(dropout_rate)
if weight:
self.weight = nn.Parameter(th.Tensor(in_feats, out_feats))
else:
self.register_parameter("weight", None)
self.reset_parameters()
def reset_parameters(self):
"""Reinitialize learnable parameters."""
if self.weight is not None:
init.xavier_uniform_(self.weight)
def forward(self, graph, feat, weight=None):
"""Compute graph convolution.
Normalizer constant :math:`c_{ij}` is stored as two node data "ci"
and "cj".
Parameters
----------
graph : DGLGraph
The graph.
feat : torch.Tensor
The input feature
weight : torch.Tensor, optional
Optional external weight tensor.
dropout : torch.nn.Dropout, optional
Optional external dropout layer.
Returns
-------
torch.Tensor
The output feature
"""
with graph.local_scope():
if isinstance(feat, tuple):
feat, _ = feat # dst feature not used
cj = graph.srcdata["cj"]
ci = graph.dstdata["ci"]
if self.device is not None:
cj = cj.to(self.device)
ci = ci.to(self.device)
if weight is not None:
if self.weight is not None:
raise DGLError(
"External weight is provided while at the same time the"
" module has defined its own weight parameter. Please"
" create the module with flag weight=False."
)
else:
weight = self.weight
if weight is not None:
feat = dot_or_identity(feat, weight, self.device)
feat = feat * self.dropout(cj)
graph.srcdata["h"] = feat
graph.update_all(
fn.copy_u(u="h", out="m"), fn.sum(msg="m", out="h")
)
rst = graph.dstdata["h"]
rst = rst * ci
return rst
class GCMCLayer(nn.Module):
r"""GCMC layer
.. math::
z_j^{(l+1)} = \sigma_{agg}\left[\mathrm{agg}\left(
\sum_{j\in\mathcal{N}_1}\frac{1}{c_{ij}}W_1h_j, \ldots,
\sum_{j\in\mathcal{N}_R}\frac{1}{c_{ij}}W_Rh_j
\right)\right]
After that, apply an extra output projection:
.. math::
h_j^{(l+1)} = \sigma_{out}W_oz_j^{(l+1)}
The equation is applied to both user nodes and movie nodes and the parameters
are not shared unless ``share_user_item_param`` is true.
Parameters
----------
rating_vals : list of int or float
Possible rating values.
user_in_units : int
Size of user input feature
movie_in_units : int
Size of movie input feature
msg_units : int
Size of message :math:`W_rh_j`
out_units : int
Size of of final output user and movie features
dropout_rate : float, optional
Dropout rate (Default: 0.0)
agg : str, optional
Function to aggregate messages of different ratings.
Could be any of the supported cross type reducers:
"sum", "max", "min", "mean", "stack".
(Default: "stack")
agg_act : callable, str, optional
Activation function :math:`sigma_{agg}`. (Default: None)
out_act : callable, str, optional
Activation function :math:`sigma_{agg}`. (Default: None)
share_user_item_param : bool, optional
If true, user node and movie node share the same set of parameters.
Require ``user_in_units`` and ``move_in_units`` to be the same.
(Default: False)
device: str, optional
Which device to put data in. Useful in mix_cpu_gpu training and
multi-gpu training
"""
def __init__(
self,
rating_vals,
user_in_units,
movie_in_units,
msg_units,
out_units,
dropout_rate=0.0,
agg="stack", # or 'sum'
agg_act=None,
out_act=None,
share_user_item_param=False,
device=None,
):
super(GCMCLayer, self).__init__()
self.rating_vals = rating_vals
self.agg = agg
self.share_user_item_param = share_user_item_param
self.ufc = nn.Linear(msg_units, out_units)
if share_user_item_param:
self.ifc = self.ufc
else:
self.ifc = nn.Linear(msg_units, out_units)
if agg == "stack":
# divide the original msg unit size by number of ratings to keep
# the dimensionality
assert msg_units % len(rating_vals) == 0
msg_units = msg_units // len(rating_vals)
self.dropout = nn.Dropout(dropout_rate)
self.W_r = nn.ParameterDict()
subConv = {}
for rating in rating_vals:
# PyTorch parameter name can't contain "."
rating = to_etype_name(rating)
rev_rating = "rev-%s" % rating
if share_user_item_param and user_in_units == movie_in_units:
self.W_r[rating] = nn.Parameter(
th.randn(user_in_units, msg_units)
)
self.W_r["rev-%s" % rating] = self.W_r[rating]
subConv[rating] = GCMCGraphConv(
user_in_units,
msg_units,
weight=False,
device=device,
dropout_rate=dropout_rate,
)
subConv[rev_rating] = GCMCGraphConv(
user_in_units,
msg_units,
weight=False,
device=device,
dropout_rate=dropout_rate,
)
else:
self.W_r = None
subConv[rating] = GCMCGraphConv(
user_in_units,
msg_units,
weight=True,
device=device,
dropout_rate=dropout_rate,
)
subConv[rev_rating] = GCMCGraphConv(
movie_in_units,
msg_units,
weight=True,
device=device,
dropout_rate=dropout_rate,
)
self.conv = dglnn.HeteroGraphConv(subConv, aggregate=agg)
self.agg_act = get_activation(agg_act)
self.out_act = get_activation(out_act)
self.device = device
self.reset_parameters()
def partial_to(self, device):
"""Put parameters into device except W_r
Parameters
----------
device : torch device
Which device the parameters are put in.
"""
assert device == self.device
if device is not None:
self.ufc.cuda(device)
if self.share_user_item_param is False:
self.ifc.cuda(device)
self.dropout.cuda(device)
def reset_parameters(self):
for p in self.parameters():
if p.dim() > 1:
nn.init.xavier_uniform_(p)
def forward(self, graph, ufeat=None, ifeat=None):
"""Forward function
Parameters
----------
graph : DGLGraph
User-movie rating graph. It should contain two node types: "user"
and "movie" and many edge types each for one rating value.
ufeat : torch.Tensor, optional
User features. If None, using an identity matrix.
ifeat : torch.Tensor, optional
Movie features. If None, using an identity matrix.
Returns
-------
new_ufeat : torch.Tensor
New user features
new_ifeat : torch.Tensor
New movie features
"""
in_feats = {"user": ufeat, "movie": ifeat}
mod_args = {}
for i, rating in enumerate(self.rating_vals):
rating = to_etype_name(rating)
rev_rating = "rev-%s" % rating
mod_args[rating] = (
self.W_r[rating] if self.W_r is not None else None,
)
mod_args[rev_rating] = (
self.W_r[rev_rating] if self.W_r is not None else None,
)
out_feats = self.conv(graph, in_feats, mod_args=mod_args)
ufeat = out_feats["user"]
ifeat = out_feats["movie"]
ufeat = ufeat.view(ufeat.shape[0], -1)
ifeat = ifeat.view(ifeat.shape[0], -1)
# fc and non-linear
ufeat = self.agg_act(ufeat)
ifeat = self.agg_act(ifeat)
ufeat = self.dropout(ufeat)
ifeat = self.dropout(ifeat)
ufeat = self.ufc(ufeat)
ifeat = self.ifc(ifeat)
return self.out_act(ufeat), self.out_act(ifeat)
class BiDecoder(nn.Module):
r"""Bi-linear decoder.
Given a bipartite graph G, for each edge (i, j) ~ G, compute the likelihood
of it being class r by:
.. math::
p(M_{ij}=r) = \text{softmax}(u_i^TQ_rv_j)
The trainable parameter :math:`Q_r` is further decomposed to a linear
combination of basis weight matrices :math:`P_s`:
.. math::
Q_r = \sum_{s=1}^{b} a_{rs}P_s
Parameters
----------
in_units : int
Size of input user and movie features
num_classes : int
Number of classes.
num_basis : int, optional
Number of basis. (Default: 2)
dropout_rate : float, optional
Dropout raite (Default: 0.0)
"""
def __init__(self, in_units, num_classes, num_basis=2, dropout_rate=0.0):
super(BiDecoder, self).__init__()
self._num_basis = num_basis
self.dropout = nn.Dropout(dropout_rate)
self.Ps = nn.ParameterList(
nn.Parameter(th.randn(in_units, in_units)) for _ in range(num_basis)
)
self.combine_basis = nn.Linear(self._num_basis, num_classes, bias=False)
self.reset_parameters()
def reset_parameters(self):
for p in self.parameters():
if p.dim() > 1:
nn.init.xavier_uniform_(p)
def forward(self, graph, ufeat, ifeat):
"""Forward function.
Parameters
----------
graph : DGLGraph
"Flattened" user-movie graph with only one edge type.
ufeat : th.Tensor
User embeddings. Shape: (|V_u|, D)
ifeat : th.Tensor
Movie embeddings. Shape: (|V_m|, D)
Returns
-------
th.Tensor
Predicting scores for each user-movie edge.
"""
with graph.local_scope():
ufeat = self.dropout(ufeat)
ifeat = self.dropout(ifeat)
graph.nodes["movie"].data["h"] = ifeat
basis_out = []
for i in range(self._num_basis):
graph.nodes["user"].data["h"] = ufeat @ self.Ps[i]
graph.apply_edges(fn.u_dot_v("h", "h", "sr"))
basis_out.append(graph.edata["sr"])
out = th.cat(basis_out, dim=1)
out = self.combine_basis(out)
return out
class DenseBiDecoder(nn.Module):
r"""Dense bi-linear decoder.
Dense implementation of the bi-linear decoder used in GCMC. Suitable when
the graph can be efficiently represented by a pair of arrays (one for source
nodes; one for destination nodes).
Parameters
----------
in_units : int
Size of input user and movie features
num_classes : int
Number of classes.
num_basis : int, optional
Number of basis. (Default: 2)
dropout_rate : float, optional
Dropout raite (Default: 0.0)
"""
def __init__(self, in_units, num_classes, num_basis=2, dropout_rate=0.0):
super().__init__()
self._num_basis = num_basis
self.dropout = nn.Dropout(dropout_rate)
self.P = nn.Parameter(th.randn(num_basis, in_units, in_units))
self.combine_basis = nn.Linear(self._num_basis, num_classes, bias=False)
self.reset_parameters()
def reset_parameters(self):
for p in self.parameters():
if p.dim() > 1:
nn.init.xavier_uniform_(p)
def forward(self, ufeat, ifeat):
"""Forward function.
Compute logits for each pair ``(ufeat[i], ifeat[i])``.
Parameters
----------
ufeat : th.Tensor
User embeddings. Shape: (B, D)
ifeat : th.Tensor
Movie embeddings. Shape: (B, D)
Returns
-------
th.Tensor
Predicting scores for each user-movie edge. Shape: (B, num_classes)
"""
ufeat = self.dropout(ufeat)
ifeat = self.dropout(ifeat)
out = th.einsum("ai,bij,aj->ab", ufeat, self.P, ifeat)
out = self.combine_basis(out)
return out
def dot_or_identity(A, B, device=None):
# if A is None, treat as identity matrix
if A is None:
return B
elif len(A.shape) == 1:
if device is None:
return B[A]
else:
return B[A].to(device)
else:
return A @ B