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

264 lines
8.8 KiB
Python

"""
An original implementation of sparsemax (Martins & Astudillo, 2016) is available at
https://github.com/OpenNMT/OpenNMT-py/blob/master/onmt/modules/sparse_activations.py.
See `From Softmax to Sparsemax: A Sparse Model of Attention and Multi-Label Classification, ICML 2016`
for detailed description.
Here we implement a graph-edge version of sparsemax where we perform sparsemax for all edges
with the same node as end-node in graphs.
"""
import dgl
import torch
from dgl._sparse_ops import _gsddmm, _gspmm
from dgl.backend import astype
from dgl.base import ALL, is_all
from dgl.heterograph_index import HeteroGraphIndex
from torch import Tensor
from torch.autograd import Function
def _neighbor_sort(
scores: Tensor,
end_n_ids: Tensor,
in_degrees: Tensor,
cum_in_degrees: Tensor,
):
"""Sort edge scores for each node"""
num_nodes, max_in_degree = in_degrees.size(0), int(in_degrees.max().item())
# Compute the index for dense score matrix with size (N x D_{max})
# Note that the end_n_ids here is the end_node tensor in dgl graph,
# which is not grouped by its node id (i.e. in this form: 0,0,1,1,1,...,N,N).
# Thus here we first sort the end_node tensor to make it easier to compute
# indexs in dense edge score matrix. Since we will need the original order
# for following gspmm and gsddmm operations, we also keep the reverse mapping
# (the reverse_perm) here.
end_n_ids, perm = torch.sort(end_n_ids)
scores = scores[perm]
_, reverse_perm = torch.sort(perm)
index = torch.arange(
end_n_ids.size(0), dtype=torch.long, device=scores.device
)
index = (index - cum_in_degrees[end_n_ids]) + (end_n_ids * max_in_degree)
index = index.long()
dense_scores = scores.new_full(
(num_nodes * max_in_degree,), torch.finfo(scores.dtype).min
)
dense_scores[index] = scores
dense_scores = dense_scores.view(num_nodes, max_in_degree)
sorted_dense_scores, dense_reverse_perm = dense_scores.sort(
dim=-1, descending=True
)
_, dense_reverse_perm = torch.sort(dense_reverse_perm, dim=-1)
dense_reverse_perm = dense_reverse_perm + cum_in_degrees.view(-1, 1)
dense_reverse_perm = dense_reverse_perm.view(-1)
cumsum_sorted_dense_scores = sorted_dense_scores.cumsum(dim=-1).view(-1)
sorted_dense_scores = sorted_dense_scores.view(-1)
arange_vec = torch.arange(
1, max_in_degree + 1, dtype=torch.long, device=end_n_ids.device
)
arange_vec = torch.repeat_interleave(
arange_vec.view(1, -1), num_nodes, dim=0
).view(-1)
valid_mask = sorted_dense_scores != torch.finfo(scores.dtype).min
sorted_scores = sorted_dense_scores[valid_mask]
cumsum_sorted_scores = cumsum_sorted_dense_scores[valid_mask]
arange_vec = arange_vec[valid_mask]
dense_reverse_perm = dense_reverse_perm[valid_mask].long()
return (
sorted_scores,
cumsum_sorted_scores,
arange_vec,
reverse_perm,
dense_reverse_perm,
)
def _threshold_and_support_graph(
gidx: HeteroGraphIndex, scores: Tensor, end_n_ids: Tensor
):
"""Find the threshold for each node and its edges"""
in_degrees = _gspmm(gidx, "copy_rhs", "sum", None, torch.ones_like(scores))[
0
]
cum_in_degrees = torch.cat(
[in_degrees.new_zeros(1), in_degrees.cumsum(dim=0)[:-1]], dim=0
)
# perform sort on edges for each node
(
sorted_scores,
cumsum_scores,
rhos,
reverse_perm,
dense_reverse_perm,
) = _neighbor_sort(scores, end_n_ids, in_degrees, cum_in_degrees)
cumsum_scores = cumsum_scores - 1.0
support = rhos * sorted_scores > cumsum_scores
support = support[dense_reverse_perm] # from sorted order to unsorted order
support = support[reverse_perm] # from src-dst order to eid order
support_size = _gspmm(gidx, "copy_rhs", "sum", None, support.float())[0]
support_size = support_size.long()
idx = support_size + cum_in_degrees - 1
# mask invalid index, for example, if batch is not start from 0 or not continuous, it may result in negative index
mask = idx < 0
idx[mask] = 0
tau = cumsum_scores.gather(0, idx.long())
tau /= support_size.to(scores.dtype)
return tau, support_size
class EdgeSparsemaxFunction(Function):
r"""
Description
-----------
Pytorch Auto-Grad Function for edge sparsemax.
We define this auto-grad function here since
sparsemax involves sort and select, which are
not derivative.
"""
@staticmethod
def forward(
ctx,
gidx: HeteroGraphIndex,
scores: Tensor,
eids: Tensor,
end_n_ids: Tensor,
norm_by: str,
):
if not is_all(eids):
gidx = gidx.edge_subgraph([eids], True).graph
if norm_by == "src":
gidx = gidx.reverse()
# use feat - max(feat) for numerical stability.
scores = scores.float()
scores_max = _gspmm(gidx, "copy_rhs", "max", None, scores)[0]
scores = _gsddmm(gidx, "sub", scores, scores_max, "e", "v")
# find threshold for each node and perform ReLU(u-t(u)) operation.
tau, supp_size = _threshold_and_support_graph(gidx, scores, end_n_ids)
out = torch.clamp(_gsddmm(gidx, "sub", scores, tau, "e", "v"), min=0)
ctx.backward_cache = gidx
ctx.save_for_backward(supp_size, out)
torch.cuda.empty_cache()
return out
@staticmethod
def backward(ctx, grad_out):
gidx = ctx.backward_cache
supp_size, out = ctx.saved_tensors
grad_in = grad_out.clone()
# grad for ReLU
grad_in[out == 0] = 0
# dL/dv_i = dL/do_i - 1/k \sum_{j=1}^k dL/do_j
v_hat = _gspmm(gidx, "copy_rhs", "sum", None, grad_in)[
0
] / supp_size.to(out.dtype)
grad_in_modify = _gsddmm(gidx, "sub", grad_in, v_hat, "e", "v")
grad_in = torch.where(out != 0, grad_in_modify, grad_in)
del gidx
torch.cuda.empty_cache()
return None, grad_in, None, None, None
def edge_sparsemax(graph: dgl.DGLGraph, logits, eids=ALL, norm_by="dst"):
r"""
Description
-----------
Compute edge sparsemax. For a node :math:`i`, edge sparsemax is an operation that computes
.. math::
a_{ij} = \text{ReLU}(z_{ij} - \tau(\z_{i,:}))
where :math:`z_{ij}` is a signal of edge :math:`j\rightarrow i`, also
called logits in the context of sparsemax. :math:`\tau` is a function
that can be found at the `From Softmax to Sparsemax <https://arxiv.org/pdf/1602.02068.pdf>`
paper.
NOTE: currently only homogeneous graphs are supported.
Parameters
----------
graph : DGLGraph
The graph to perform edge sparsemax on.
logits : torch.Tensor
The input edge feature.
eids : torch.Tensor or ALL, optional
A tensor of edge index on which to apply edge sparsemax. If ALL, apply edge
sparsemax on all edges in the graph. Default: ALL.
norm_by : str, could be 'src' or 'dst'
Normalized by source nodes of destination nodes. Default: `dst`.
Returns
-------
Tensor
Sparsemax value.
"""
# we get edge index tensors here since it is
# hard to get edge index with HeteroGraphIndex
# object without other information like edge_type.
row, col = graph.all_edges(order="eid")
assert norm_by in ["dst", "src"]
end_n_ids = col if norm_by == "dst" else row
if not is_all(eids):
eids = astype(eids, graph.idtype)
end_n_ids = end_n_ids[eids]
return EdgeSparsemaxFunction.apply(
graph._graph, logits, eids, end_n_ids, norm_by
)
class EdgeSparsemax(torch.nn.Module):
r"""
Description
-----------
Compute edge sparsemax. For a node :math:`i`, edge sparsemax is an operation that computes
.. math::
a_{ij} = \text{ReLU}(z_{ij} - \tau(\z_{i,:}))
where :math:`z_{ij}` is a signal of edge :math:`j\rightarrow i`, also
called logits in the context of sparsemax. :math:`\tau` is a function
that can be found at the `From Softmax to Sparsemax <https://arxiv.org/pdf/1602.02068.pdf>`
paper.
Parameters
----------
graph : DGLGraph
The graph to perform edge sparsemax on.
logits : torch.Tensor
The input edge feature.
eids : torch.Tensor or ALL, optional
A tensor of edge index on which to apply edge sparsemax. If ALL, apply edge
sparsemax on all edges in the graph. Default: ALL.
norm_by : str, could be 'src' or 'dst'
Normalized by source nodes of destination nodes. Default: `dst`.
NOTE: currently only homogeneous graphs are supported.
Returns
-------
Tensor
Sparsemax value.
"""
def __init__(self):
super(EdgeSparsemax, self).__init__()
def forward(self, graph, logits, eids=ALL, norm_by="dst"):
return edge_sparsemax(graph, logits, eids, norm_by)